Skip to content

feat(git): widen worktree symlink candidates#232

Open
FourWindff wants to merge 4 commits into
johannesjo:mainfrom
FourWindff:feat/widen-worktree-symlink-candidates
Open

feat(git): widen worktree symlink candidates#232
FourWindff wants to merge 4 commits into
johannesjo:mainfrom
FourWindff:feat/widen-worktree-symlink-candidates

Conversation

@FourWindff

Copy link
Copy Markdown
Contributor

Description

Related to #231. This is PR 1 of 2 and only widens candidate enumeration; persisted per-project selection and symlink-vs-copy behavior remain follow-up work.

Summary

  • Discover every fully ignored top-level file or directory with a single git ls-files --others --ignored --exclude-standard --directory call.
  • Keep the existing eight candidates checked by default while newly discovered entries start unchecked.
  • Prevent ignored files nested inside tracked directories from producing ineffective top-level checkboxes.
  • Keep remote task creation on the existing safe behavior by passing only default candidates.

Implementation details

GetGitignoredDirs now returns { name, isDefault }[]. The backend owns the default-candidate classification, so the desktop and remote consumers do not duplicate the hardcoded list.

Enumeration preserves Git's path order and filters Parallel Code-managed entries that should never be symlink choices:

  • .claude, because it is seeded by copying for Claude's bwrap sandbox rather than symlinked.
  • .worktrees, because it is Parallel Code's managed worktree container. The similarly named .worktree remains a valid user entry.
  • The nine sandbox bind-mount artifact basenames, such as .mcp.json and .ripgreprc. Parallel Code writes these patterns to .git/info/exclude; without filtering, its own bookkeeping would reappear as phantom picker options.

The New Task dialog renders all returned entries but initially selects only those marked isDefault. Remote task creation also uses only the default subset because it has no confirmation UI for newly discovered entries.

Tests

Added integration coverage against real temporary Git repositories for:

  • default and newly discovered ignored directories;
  • ignored top-level files;
  • ignored files nested under tracked directories;
  • non-ignored default candidates;
  • .claude, .worktrees, and all nine sandbox artifact exclusions;
  • preservation of the user-owned singular .worktree name;
  • createWorktree symlink creation and nested-name rejection.

Notes and follow-ups

  • .git/info/exclude growth remains intentionally one-way and has no new marker-block machinery. Newly discovered entries are unchecked, so new root-anchored exclusions are appended only after explicit desktop opt-in and remain idempotent.
  • MCP coordinator and arena retain their existing hardcoded symlink lists. This asymmetry predates this change; unification belongs with PR 2's persisted per-project selection.
  • PR 2 will cover persisted symlink-vs-copy selection and will be discussed on Configurable worktree include: bring git-ignored files in, carry untracked files back on merge #231 before implementation.

@johannesjo johannesjo left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for this — the split is exactly what we agreed on #231, keeping isDefault classification in the backend is the right call, and the real-git integration tests are a genuinely valuable addition (first coverage these functions have had).

I ran the enumeration against a few repo shapes before merging and found one thing I think blocks.

--directory also collapses directories that aren't themselves ignored

--directory collapses any untracked directory whose contents are all ignored — the directory itself doesn't have to match a rule. So with content-pattern gitignores, which are very common:

.gitignore enumerated git check-ignore on it
*.log + a logs/ dir logs not ignored
coverage/* coverage not ignored
*.tmp + a tmp/ dir tmp not ignored

This repo's own .gitignore uses bare directory names (node_modules, dist, coverage), so it doesn't reproduce here — which is probably why it got through review and CI.

The consequence is the one-way door from the issue. If a user ticks logs, createWorktree symlinks it and ensureSymlinkExcludes appends /logs to the main repo's .git/info/exclude — permanently hiding a directory the user never ignored from git status, in the main checkout and in every worktree, with no removal path.

Verified end-to-end against this branch:

picker offers -> [{"name":"logs","isDefault":false}]

MAIN .git/info/exclude now contains:
  ...
  # parallel-code: worktree symlinks
  /logs

Smaller: core.quotePath mangles non-ASCII names

Paths are parsed without -z, so git C-quotes them. Verified on this branch:

  • ignored dir dàta/ → git emits "d\303\240ta/". The trailing " defeats endsWith('/'), so the slash is never stripped and !name.includes('/') drops the entry entirely → [], the directory is silently unselectable.
  • ignored file nöte.txt → survives as "n\303\266te.txt", renders with literal quotes and escapes in the picker, then silently no-ops in createWorktree because fs.existsSync is false.

Both fall out of one change

export async function getGitIgnoredDirs(projectRoot: string): Promise<GitIgnoredEntry[]> {
  // `-z` so non-ASCII names aren't C-quoted by core.quotePath.
  const { stdout } = await exec(
    'git',
    ['ls-files', '-z', '--others', '--ignored', '--exclude-standard', '--directory'],
    { cwd: projectRoot },
  );
  const candidates = stdout
    .split('\0')
    .filter(Boolean)
    .map((entry) => (entry.endsWith('/') ? entry.slice(0, -1) : entry))
    .filter((name) => !name.includes('/') && !INTERNAL_SYMLINK_EXCLUSIONS.has(name));

  // `--directory` also collapses untracked directories whose *contents* are all
  // ignored (e.g. `logs/` under a bare `*.log` rule). Such a directory is not
  // itself ignored, and symlinking it would append a permanent `/logs` line to
  // the main repo's .git/info/exclude. Keep only self-ignored entries.
  const checked = await Promise.all(
    candidates.map(async (name) => {
      try {
        await exec('git', ['check-ignore', '-q', '--', name], { cwd: projectRoot });
        return name;
      } catch {
        return null;
      }
    }),
  );
  return checked
    .filter((name): name is string => name !== null)
    .map((name) => ({ name, isDefault: DEFAULT_SYMLINK_CANDIDATES.has(name) }));
}

check-ignore -z only works with --stdin, and promisify(execFile) can't write stdin — hence per-candidate calls rather than one batched one. Candidates are top-level only (8 in this repo) and the old implementation already did up to 16 execs, so it isn't a cost regression.

I applied that on top of your branch locally: your 9 tests still pass, plus 5 new ones covering *.log + logs/, coverage/*, both non-ASCII cases, and an end-to-end assertion that /logs never reaches .git/info/exclude. Full suite 841 passed, typecheck clean.

Worth adding a regression test for the collapsed-but-not-ignored case — the current suite covers ignored files under a tracked directory, but not an untracked directory whose contents are all ignored, which is the inverse.

Two non-blocking notes

  • src/store/remoteTaskHandler.ts:58getGitIgnoredDirs can now reject, where the old per-candidate try/catch always resolved []. NewTaskDialog catches and falls back, but here the throw fails the entire remote task creation, where it previously degraded to "no symlinks". Narrow trigger, since GetMainBranch usually throws first — though it's skipped when defaultBaseBranch is set, which leaves this as the first git call. Probably worth catching and returning [].
  • src/components/SymlinkDirPicker.tsx — nothing distinguishes the pre-checked defaults from newly discovered entries, and git's ordering interleaves them. With the list growing from 8 to potentially dozens, a max-height + scroll would help; NewTaskDialog.tsx:262 already uses that pattern. Longer term it'd also be good to surface that ticking a box writes a permanent .git/info/exclude line.

The .claude / .worktrees / sandbox-artifact exclusion reasoning is all correct, and the .worktree vs .worktrees test is a nice catch. Just the enumeration predicate to tighten.

FourWindff and others added 2 commits July 24, 2026 18:37
- Use git ls-files -z to receive unquoted, NUL-terminated paths so
  core.quotePath cannot corrupt non-ASCII candidate names.
- Filter each candidate through git check-ignore -q so only paths
  that are themselves ignored are returned, preventing collapsed
  content-ignored directories from being written to .git/info/exclude.
- Rename getGitIgnoredDirs to getSymlinkCandidates.
- Add regression tests for collapsed-but-not-ignored dirs and
  non-ASCII paths.

Co-Authored-By: Claude <noreply@anthropic.com>
getSymlinkCandidates now swallows probe errors (missing git, broken
repo) with a console.warn and returns [], so remote task creation
proceeds without symlinks — matching the pre-verification behavior.

Also cap the symlink picker at 160px with internal scroll, and add a
muted disclosure line that checked entries are written to
.git/info/exclude and apply to all worktrees.

Co-Authored-By: Claude <noreply@anthropic.com>
@johannesjo

Copy link
Copy Markdown
Owner

Thanks for the follow-up commits — the original --directory/Unicode issues and the remote/UI contract notes from the earlier review are addressed. I rechecked the current head (9c2dba8): both CI jobs are green, and the 29 targeted Git/worktree tests pass.

I also did a fresh adversarial end-to-end pass and found these remaining issues:

Blocking

  1. Arbitrary basenames are written as gitignore patterns, not literals. The candidate path now accepts any root basename, but ensureSymlinkExcludes writes /${name} verbatim into the shared .git/info/exclude. Repro: with .gitignore containing star?file, an ignored file named star*file, and an unrelated visible starZZfile, selecting star*file appends /star*file and Git then hides starZZfile too. Newline-containing names can inject additional patterns. The substring dedupe is also incorrect: an existing /foobar makes existing.includes('/foo') suppress the required /foo, so the selected foo symlink remains visible. Please escape names as literal gitignore entries, reject CR/LF, and dedupe exact lines.

  2. Option-like filenames can block discovery. git check-ignore -q ${name} has no --. An ignored root entry named --stdin becomes git check-ignore -q --stdin; in a direct probe the child remained blocked on stdin until killed, and this call has no timeout. Other dash-prefixed names are silently dropped as options. Pass an option terminator and a literal path such as ./${name}, or use a deliberately closed/batched stdin probe.

  3. The discovery probe is unbounded and can silently lose all candidates in large repos. The ls-files call uses Node's default execFile buffer, while --directory can still emit every ignored file below a tracked directory. A fixture with a tracked src/, 15,000 ignored nested files, and a valid root node_modules produced more than 1 MiB; the call rejected and the outer catch returned [], losing node_modules as well. Separately, Promise.all launches one Git process per root candidate. Please use the existing larger buffer and batch or strictly bound the verification work.

  4. Managed-name comparisons are case-sensitive on supported macOS repositories. DEFAULT_SYMLINK_CANDIDATES and INTERNAL_SYMLINK_EXCLUSIONS compare exact casing, but with core.ignorecase=true Git returns the on-disk casing. I verified that .WORKTREES, .CLAUDE, and Node_Modules are all offered as non-default entries. On a case-insensitive volume, selecting .WORKTREES links the worktree container into its own child, while .CLAUDE bypasses the special copy path. Respect core.ignorecase and repeat the reserved-name guard defensively in createWorktree.

  5. Discovered opt-ins leak across dialog lifecycles, and project switches can submit stale names. NewTaskDialog remains mounted, while the candidate effect tracks only selectedProjectId and does not clear state before awaiting the probe. Check a newly discovered entry, cancel, and reopen the same project: it remains checked because assigning the same project ID does not rerun the effect. During a project switch, project A's selection also remains usable after B's branch load finishes but before B's slower candidate probe does, because canSubmit does not track candidate loading. If B has an untracked same-name entry, the backend can symlink it and add it to B's shared exclude file. Clear/refetch on every open/project change, track the current request, and block submission until it settles. The generic lifecycle gap predates this PR, but arbitrary, initially-unchecked opt-ins make it newly material and contradict the per-task opt-in contract in Configurable worktree include: bring git-ignored files in, carry untracked files back on merge #231.

Smaller mismatch

  • Discovery offers valid POSIX basenames such as foo..bar or names containing \, but createWorktree silently rejects them. Please share one accepted-name predicate between producer and consumer, or narrow the consumer guard to actual path separators/traversal components.

The backend-owned defaults and real-Git tests are good additions, but the issues above make my current verdict Needs changes.

… and stale UI state

Single git ls-files call with :(glob) pathspec magic replaces the
unbounded enumeration + per-candidate check-ignore fan-out, bounding
output by root entry count and eliminating the --stdin hang.

- ensureSymlinkExcludes: escape names to gitignore literals, reject
  CR/LF, dedup per exact line instead of substring
- Reserved-name checks (.claude, .worktrees, sandbox artifacts) fold
  case per the repo's core.ignorecase, enforced defensively in
  createWorktree so the backend doesn't trust the UI's list
- New Task dialog: candidate state clears before every load, stale
  responses are dropped via a request id, and submit is blocked while
  candidates for the current project are unresolved
- Single isValidSymlinkName predicate shared by producer and consumer;
  names like foo..bar are legal again, only full-component ".." is
  rejected

Co-Authored-By: Claude <noreply@anthropic.com>
@FourWindff

Copy link
Copy Markdown
Contributor Author

Thanks for the detailed adversarial pass — all six points are addressed in 100aea5 (pushed). Point by point:

1. Arbitrary basenames written as gitignore patternsensureSymlinkExcludes now escapes names to gitignore literals before writing (\, *, ?, [, ], leading !/#), refuses names containing CR/LF with a warning, and dedupes per exact line instead of substring matching. Tests: escapes gitignore wildcards so similarly-named files stay visible (real repo, verified via git status — after excluding star*file, the unrelated starZZfile remains visible), writes /foo even when /foobar is already present, rejects names containing newlines and warns instead of writing.

2. Option-like filenames blocking discovery — Eliminated with the code itself: the per-candidate check-ignore re-verification loop is deleted entirely. Discovery is now a single git ls-files -z --others --ignored --exclude-standard --directory -- ':(glob)*' ':(glob).*' call. The glob magic keeps * from crossing /, so only root-level entries are returned — and no candidate name is ever passed back to git as an argument, so there is nothing left to parse --stdin as a flag. Test: returns a root-level ignored file named like a git flag without hanging.

3. Unbounded probe / process fan-out — Same refactor: output size is bounded by the root entry count (nested ignored files below tracked directories can no longer match the root-anchored globs), the call uses the module's existing 10 MB MAX_BUFFER, and exactly one git process runs regardless of candidate count. Test: finds root candidates even above a tracked directory full of ignored files — a tracked src/ with 15,000 ignored nested files plus a root-level node_modules returns exactly [node_modules].

4. Case-sensitive managed-name comparisons — Reserved-name and default-candidate comparisons now fold case per the repo's core.ignorecase (read via git config --get core.ignorecase, defaulting to case-sensitive on failure), and createWorktree repeats the reserved-name guard defensively on the consume side, so the backend never trusts the UI's list. With core.ignorecase=true, .WORKTREES and .CLAUDE are filtered out; a .CLAUDE name can never become a symlink, so the special copy path always runs. And your observation about Node_Modules being offered as non-default is fixed by the same change: it's now offered with isDefault: true — it's a legitimate, creatable entry, so it stays visible, just correctly classified. Tests: filters case variants of reserved names when core.ignorecase is true, refuses a case variant of the worktree container when core.ignorecase is true.

5. Dialog opt-in leakage — The picker state is extracted to src/lib/symlink-candidates.ts and now: clears dirs and selection before every load (not only when skipping), re-runs on every dialog open so cancel → reopen resets to the default selection (restoring the per-task opt-in contract), tags each probe with a monotonic request id so a late response from a previous project is dropped instead of overwriting current state, and gates canSubmit on the candidate probe having settled — mirroring the branch-loading handling. Six unit tests cover reset-on-reopen, clear-up-front, stale-response drop, and invalidate-on-close. One caveat: there's no component-level assertion of the disabled submit button — the test environment is node without jsdom, so the gating input (loading()) is unit-tested directly instead. Happy to add a jsdom-based test if you'd like the wiring itself covered.

6. Producer/consumer validation mismatch — A single exported isValidSymlinkName predicate is now shared by getSymlinkCandidates, createWorktree, and ensureSymlinkExcludes, so anything offered is creatable by construction. It rejects empty names, ., .., path separators, and CR/LF, but no longer rejects .. as a substring — foo..bar now symlinks end to end. Backslash-containing names stay rejected, but they're now filtered at discovery too, so they can never be offered and silently dropped. Test: creates a symlink for a name containing a .. substring.

Full suite is green: 1604 tests passing, including all 29 pre-existing candidate/worktree contract tests unchanged.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants