Conversation
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/.
Deploying ralphex with
|
| 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 |
There was a problem hiding this comment.
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 inCreateBranchForPlan,CreateWorktreeForPlan, andCommitPlanFileto avoid wrong-casegit 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.
| 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 | ||
| } |
There was a problem hiding this comment.
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.
| } | ||
| // 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) |
There was a problem hiding this comment.
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.
| ## 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). | ||
|
|
There was a problem hiding this comment.
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.
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:
The os.ReadDir + EqualFold approach works identically on both case-sensitive (Linux) and case-insensitive (macOS) filesystems.