Skip to content

fix: case-insensitive path handling for worktree plan commits on macOS#266

Merged
umputun merged 1 commit intomasterfrom
fix-worktree-plan-commit-case-mismatch
Apr 3, 2026
Merged

fix: case-insensitive path handling for worktree plan commits on macOS#266
umputun merged 1 commit intomasterfrom
fix-worktree-plan-commit-case-mismatch

Conversation

@umputun
Copy link
Copy Markdown
Owner

@umputun umputun commented Apr 3, 2026

Fixes #265

On macOS case-insensitive APFS, when a plan branch already exists from a previous attempt, the plan file may be tracked in git index with different case than the path ralphex computes. git add receives the wrong-case path and silently fails to stage, causing git commit to fail with "no changes added to commit".

Root cause: CommitPlanFile computes localPlan from the main repo plan file path, preserving the caller case. When the worktree git index tracks the file with different case (from a previous commit), git add with the mismatched case does not stage the file.

Fix:

  • Add resolveFilesystemCase() method that reads the parent directory and finds the actual on-disk filename case via os.ReadDir + strings.EqualFold
  • Apply it in CommitPlanFile, CreateBranchForPlan, and CreateWorktreeForPlan before staging
  • Change hasChangesOtherThan to use strings.EqualFold instead of == for plan file exclusion, preventing false "uncommitted changes" errors with case mismatches

The os.ReadDir + EqualFold approach works identically on both case-sensitive (Linux) and case-insensitive (macOS) filesystems.

Add resolveFilesystemCase() to resolve plan file paths to actual on-disk
case, fixing CommitPlanFile failures on macOS APFS case-insensitive
filesystems. Apply case-insensitive comparison in hasChangesOtherThan
for plan file exclusion. Move completed plan to docs/plans/completed/.
Copilot AI review requested due to automatic review settings April 3, 2026 17:06
@cloudflare-workers-and-pages
Copy link
Copy Markdown

Deploying ralphex with  Cloudflare Pages  Cloudflare Pages

Latest commit: 8ba4768
Status: ✅  Deploy successful!
Preview URL: https://c80d2bd5.ralphex.pages.dev
Branch Preview URL: https://fix-worktree-plan-commit-cas.ralphex.pages.dev

View logs

Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Fixes plan-file staging/commit failures on macOS case-insensitive filesystems by normalizing plan file paths to the actual on-disk filename casing before git operations, and by making the “exclude plan file” dirty-check logic case-insensitive.

Changes:

  • Add resolveFilesystemCase() and apply it in CreateBranchForPlan, CreateWorktreeForPlan, and CommitPlanFile to avoid wrong-case git add/pathspec issues.
  • Update hasChangesOtherThan() to exclude the plan file using case-insensitive comparison.
  • Add unit tests covering case-mismatched paths for branch creation, worktree commits, and dirty-file exclusion.

Reviewed changes

Copilot reviewed 6 out of 6 changed files in this pull request and generated 3 comments.

Show a summary per file
File Description
pkg/git/service.go Resolves plan file paths to actual on-disk case before deriving branch names and staging/committing.
pkg/git/service_test.go Adds regression tests for committing/branching with case-mismatched plan paths and tests resolveFilesystemCase.
pkg/git/external.go Makes plan-file exclusion in hasChangesOtherThan case-insensitive.
pkg/git/external_test.go Adds test ensuring dirty-check exclusion works even when path case differs.
docs/plans/completed/20260403-fix-worktree-plan-commit-case-mismatch.md Documents the fix and approach for #265.
CLAUDE.md Updates internal documentation to note case-insensitive handling behavior and where it lives.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread pkg/git/service.go
Comment on lines +405 to +416
var foldMatch string
for _, entry := range entries {
if entry.Name() == base {
return path // exact match, no case resolution needed
}
if foldMatch == "" && strings.EqualFold(entry.Name(), base) {
foldMatch = filepath.Join(dir, entry.Name())
}
}
if foldMatch != "" {
return foldMatch
}
Copy link

Copilot AI Apr 3, 2026

Choose a reason for hiding this comment

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

resolveFilesystemCase() returns the first case-insensitive match found in the directory. On case-sensitive filesystems it’s possible (and valid for git) to have multiple entries that are EqualFold-equivalent (e.g., Foo.md and foo.md); in that scenario this function will pick one non-deterministically based on ReadDir order, potentially staging/committing the wrong file. Consider detecting multiple fold matches and falling back to the original path (or returning an error) when the match is ambiguous.

Copilot uses AI. Check for mistakes.
Comment thread pkg/git/external.go
Comment on lines 286 to 292
}
// extract file path from porcelain output: "XY path" or "XY path -> newpath"
filePath := e.extractPathFromPorcelain(line)
if filePath == rel {
if strings.EqualFold(filePath, rel) {
continue
}
dirty = append(dirty, filePath)
Copy link

Copilot AI Apr 3, 2026

Choose a reason for hiding this comment

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

hasChangesOtherThan() now excludes the target path using strings.EqualFold. On case-sensitive filesystems, two distinct tracked files can differ only by case; this change would exclude both from the dirty list, potentially allowing unrelated uncommitted changes to slip through. If the intent is to handle macOS case-insensitive filesystems, consider gating the case-insensitive compare to known case-insensitive environments or using a safer strategy that doesn’t collapse distinct paths on case-sensitive FS.

Copilot uses AI. Check for mistakes.
Comment on lines +24 to +33
## Technical Details

**`resolveFilesystemCase(path string) string`** — standalone unexported function in `pkg/git/external.go`. Reads `os.ReadDir(dir)` and finds a case-insensitive match for the basename via `strings.EqualFold`. Returns the path with the actual on-disk case. Falls back to the original path if directory can't be read or no match is found. This is a standalone function (not on `backend` interface) because it's pure filesystem logic with no git operations.

Note: the `os.ReadDir` + `EqualFold` approach works identically on both case-sensitive (Linux) and case-insensitive (macOS) filesystems — it always scans directory entries and matches by fold, regardless of OS behavior.

**`CommitPlanFile`** — after computing `localPlan`, call `resolveFilesystemCase` to canonicalize the path before passing to `add()`.

**`CreateBranchForPlan`** — same fix: call `resolveFilesystemCase(planFile)` before `s.repo.add(planFile)` at line 248. Same bug class, different code path (non-worktree mode with existing branch).

Copy link

Copilot AI Apr 3, 2026

Choose a reason for hiding this comment

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

This completed plan document says resolveFilesystemCase is implemented as a standalone helper in pkg/git/external.go, but the implementation in this PR is a Service method in pkg/git/service.go. Updating this note will prevent future readers from chasing the wrong file/location.

Copilot uses AI. Check for mistakes.
@umputun umputun merged commit 03c0866 into master Apr 3, 2026
9 checks passed
@umputun umputun deleted the fix-worktree-plan-commit-case-mismatch branch April 3, 2026 17:13
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.

[bug] Plan commit fails in worktree when branch already exists (missing git add)

2 participants