Skip to content

feat(git): widen worktree symlink candidates - #232

Merged
johannesjo merged 6 commits into
johannesjo:mainfrom
FourWindff:feat/widen-worktree-symlink-candidates
Aug 1, 2026
Merged

feat(git): widen worktree symlink candidates#232
johannesjo merged 6 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.

@johannesjo

Copy link
Copy Markdown
Owner

Thanks for the thorough follow-up. I rechecked the current head (100aea5) from scratch against #231 and the prior review, refreshed the full diff/discussion/CI, ran typecheck and the full 1,604-test suite, and built fresh real-Git repros. The root-only NUL-delimited probe and request-id lifecycle do address the option-like filename hang, process fan-out/nested output, and stale worktree selections.

I still found two blockers:

Blocking

  1. Whitespace basenames are still not handled literally end to end. escapeGitignoreLiteral does not escape trailing ASCII spaces even though isValidSymlinkName accepts them. Repro: a directory named foo ignored by a directory-only rule is discovered and symlinked, but ensureSymlinkExcludes writes /foo . Git strips that trailing space, so git check-ignore returns 1 for the selected foo symlink and 0 for an unrelated root entry named foo: the selected symlink remains visible while the unrelated entry is hidden by the shared .git/info/exclude. Please escape trailing spaces (or reject them consistently) and add a real-Git regression.

    The exact-line fix is also undone downstream: ensureSymlinkExcludes computes exact missing lines, then the append helper trims each existing line. With existing content /foo\n and requested names foo, bar, the helper returns present and appends neither /foo nor /bar. Please use exact marker semantics for this caller or bypass the helper's marker recheck after toAdd is computed.

  2. core.ignorecase parsing does not use Git's boolean semantics. getCoreIgnoreCase recognizes only raw true, while Git also accepts and preserves yes, on, and 1. With core.ignorecase=yes, my repro returned .CLAUDE, .WORKTREES, and Node_Modules as non-default candidates, and the backend consumer created the .WORKTREES symlink. On a case-insensitive macOS checkout this reopens the worktree-container/self-link and .claude bwrap hazards. git config --bool --get core.ignorecase canonicalizes these spellings correctly; please add a synonym regression test for both discovery and the defensive consumer guard.

Smaller regressions / test gap

  • canSubmit unconditionally waits for candidates, although Direct mode sends no symlinkDirs. Branch and candidate IPC resolve independently, so a slow probe unnecessarily delays a project configured for Current Branch; a hung probe blocks it indefinitely. Gate candidate loading only for Worktree mode and cover that wiring.
  • The 15,000-file buffer regression emits exactly 213,904 bytes (~209 KiB), well below the old 1 MiB execFile default. It therefore passes the pre-fix implementation and does not prove the stated overflow. Fewer near-maximum-length basenames would exceed 1 MiB while keeping the test cheaper.

Both CI checks and all existing tests are green, but they do not cover the repros above. My verdict remains Needs changes.

…orecase bool, probe gating

- Match exclude markers with gitignore semantics: strip trailing
  whitespace only, so a hand-written line with leading spaces no longer
  suppresses appends
- Escape trailing spaces in exclude patterns (`foo ` → `foo\ `) so they
  match the exact name instead of the trimmed one
- Parse core.ignorecase via `git config --bool` so yes/on/1/case
  variants enable case folding; both consumers (candidate discovery and
  the createWorktree reserved-name guard) covered by a synonym matrix
- Gate the symlink-candidate probe and submit blocking on worktree
  mode: direct-mode tasks never send symlinkDirs, so no probe IPC fires
  and a pending probe can't block submit
- Make the buffer regression test actually exceed the old 1 MiB
  execFile default (5000 × 240-char names ≈ 1.2 MB)

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

Copy link
Copy Markdown
Contributor Author

Thanks for the detailed re-review — all four points are addressed in a5c842d.

1a. Trailing spacesescapeGitignoreLiteral now escapes each trailing space (foo /foo\ ) instead of rejecting the name, since the NUL probe already handles such names and rejecting would be a feature regression. Real-Git regression in git-worktree.test.ts: a directory foo ignored by a directory-only rule is discovered and symlinked, and after the exclude write git check-ignore 'foo ' exits 0 while git check-ignore foo exits 1.

1b. Marker semantics — the append helper now compares with trailing-whitespace-only stripping (line.replace(/\s+$/, '') === marker) instead of trim(), matching gitignore semantics. Regression: existing content /foo\n with requested foo, bar now appends both /foo and /bar.

2. core.ignorecase — switched to git config --bool --get core.ignorecase, so yes/on/1/case variants canonicalize to true/false and invalid values exit non-zero into the existing catch → false. Both consumers get an end-to-end synonym matrix (true, yes, on, 1, TRUE): discovery filters .CLAUDE/.WORKTREES while still offering Node_Modules as a default, and the createWorktree guard blocks the .WORKTREES symlink for every spelling.

3. Direct-mode gating — both gates now key off the isolation mode: the load effect reads gitIsolation() (reactively, so switching modes re-fires it) and skips the probe IPC entirely outside worktree mode, and canSubmit only blocks on a pending probe in worktree mode. The two predicates are extracted to src/lib/symlink-candidates.ts with unit tests (skipped probe → no fetch, no loading state; pending probe blocks submit only in worktree mode). One caveat: the repo's vitest runs in a node environment with no DOM-rendering setup, so these are helper/state-level tests rather than rendered-component tests — the tsx wiring is a direct read of those predicates.

4. Buffer regression — replaced with 5,000 files of 240-char basenames (just under the 255 fs limit), which would produce ~1.2 MB of output, ~17% over the old 1 MiB execFile default. The 60 s timeout is kept.

Typecheck is clean and the full suite stays green (1,618 passed, 25 skipped).

@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 the follow-up. I re-reviewed the full current head (a5c842d) from scratch. The earlier trailing-space filename, core.ignorecase, buffer-test, and direct-mode issues are addressed, and the full suite/checks are green. One blocking marker issue remains.

Blocking: the block marker can suppress unrelated missing exclusions

ensureSymlinkExcludes first computes toAdd using exact line equality, then passes only toAdd[0] as the marker for the entire multi-line block. appendGitInfoExcludeBlockAtPath applies different normalization to that marker and returns present without writing any part of the block.

Current-head repro:

fs.writeFileSync(excludePath, '/foo \n');
ensureSymlinkExcludes(root, ['foo', 'bar']);
expect(fs.readFileSync(excludePath, 'utf8')).toContain('/bar\n');

This fails: the exact prefilter leaves both /foo and /bar in toAdd, the helper strips the trailing space from the existing /foo line, treats the first marker as present, and suppresses the whole append. /bar is never written, so a selected bar/ directory that becomes a symlink can remain visible to Git and be staged accidentally.

There is a second manifestation in the same comparison: \s+$ also strips tabs and Unicode whitespace, but Git only ignores unescaped trailing ASCII spaces. An existing /foo<TAB> pattern is distinct to Git, yet the helper incorrectly treats it as /foo and suppresses the append. I reproduced both cases against a5c842d with failing regression tests.

Please avoid using one marker to gate the entire toAdd block (or bypass the helper's marker recheck after the caller has computed the missing lines), and use one consistent comparison that preserves tabs/escaped spaces while handling CRLF and unescaped trailing ASCII spaces. A regression with existing /foo \n plus requested foo, bar, and one with /foo\t\n, would cover both failure modes.

Verification: clean-head full suite 1,618 passed / 25 skipped; compile, typecheck, lint, formatting, and both CI checks pass. Verdict remains Needs changes for the correctness issue above.

…ison

ensureSymlinkExcludes computed the missing lines with exact equality, then
passed only toAdd[0] as the marker for the whole multi-line block. The
helper re-checked that marker with different normalization (\s+$ strip), so
a hand-written `/foo ` or `/foo<TAB>` line could mark the block present and
silently drop genuinely missing entries like /bar.

- Add normalizeExcludeLine (CR + unescaped trailing ASCII spaces only;
  tabs/escaped spaces stay significant) and use it on both sides of the
  comparison.
- Add skipMarkerCheck so callers that already computed the missing lines
  bypass the single-marker recheck.
- Regressions: `/foo \n` + foo,bar and `/foo\t\n` at both mock level and
  with real-git check-ignore, plus CRLF and escaped-space locking tests.

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

Copy link
Copy Markdown
Contributor Author

Thanks for the detailed repro — fixed in b2b57aa.

Root cause: as you described, the caller computed toAdd with exact equality while the helper re-checked a single marker with \s+$ stripping, so a hand-written /foo or /foo<TAB> line could suppress the whole block.

Fix:

  • Added normalizeExcludeLine in git-exclude.ts — strips only CR (CRLF) and unescaped trailing ASCII spaces; tabs, Unicode whitespace, and \ -escaped spaces stay significant. Both the caller's prefilter and the helper's marker check now use it, so the comparison is one consistent rule.
  • Added a skipMarkerCheck parameter to appendGitInfoExcludeBlockAtPath. ensureSymlinkExcludes passes it since it has already computed exactly the missing lines — the single-marker recheck no longer gates the multi-line block. The other four callers (single-line markers / fixed blocks) are unchanged.

Regressions added (mock-level in git-symlink-excludes.test.ts, plus real-git check-ignore verification in git-helpers.test.ts):

  • /foo \n + requested foo, bar → only /bar appended, and check-ignore passes for both
  • /foo\t\n + requested foo/foo appended, check-ignore passes
  • Plus CRLF (/foo\r\n treated as duplicate) and escaped-space (/foo\ \n treated as distinct) locking tests

Full suite: 1,624 passed / 25 skipped; compile, typecheck, and lint clean.

One disclosed simplification: normalizeExcludeLine inspects only the final space for the \ escape check, so /foo\ (escaped space followed by an unescaped one) is normalized imprecisely — marked with a ponytail: comment, since such lines don't occur in real exclude files.

@johannesjo

Copy link
Copy Markdown
Owner

Thank you very much for this! <3

@johannesjo
johannesjo merged commit 054060b into johannesjo:main Aug 1, 2026
2 checks passed
johannesjo pushed a commit that referenced this pull request Aug 1, 2026
…ol, probe gating

- Match exclude markers with gitignore semantics: strip trailing
  whitespace only, so a hand-written line with leading spaces no longer
  suppresses appends
- Escape trailing spaces in exclude patterns (`foo ` → `foo\ `) so they
  match the exact name instead of the trimmed one
- Parse core.ignorecase via `git config --bool` so yes/on/1/case
  variants enable case folding; both consumers (candidate discovery and
  the createWorktree reserved-name guard) covered by a synonym matrix
- Gate the symlink-candidate probe and submit blocking on worktree
  mode: direct-mode tasks never send symlinkDirs, so no probe IPC fires
  and a pending probe can't block submit
- Make the buffer regression test actually exceed the old 1 MiB
  execFile default (5000 × 240-char names ≈ 1.2 MB)

Co-Authored-By: Claude <noreply@anthropic.com>
@FourWindff
FourWindff deleted the feat/widen-worktree-symlink-candidates branch August 1, 2026 16:12
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