From d8054b72ebcfcfb29a1d0b928e24ad9337aefccf Mon Sep 17 00:00:00 2001 From: Matthew Gribben Date: Thu, 6 Aug 2026 18:40:26 +1000 Subject: [PATCH] Fix macOS hypa_read sed slicing and git worktree project-root detection BSD sed rejects -- as end-of-options, so hypa_read offset/limit polluted stderr and failed nonzero on macOS. Drop -- on the sed branch and prefix leading-dash paths with ./ so sed does not parse them as options. Git worktrees (and submodules) use a .git file rather than a directory; treat either form as a project-root marker so hypa read resolves against the worktree instead of walking past it. Closes #89 --- packages/pi-hypa/extensions/tools.ts | 11 +++-- packages/pi-hypa/test/tools.test.ts | 22 +++++++++- .../ProjectRoot/GitProjectRootDetector.cs | 5 ++- .../GitProjectRootDetectorTests.cs | 43 +++++++++++++++++++ 4 files changed, 76 insertions(+), 5 deletions(-) diff --git a/packages/pi-hypa/extensions/tools.ts b/packages/pi-hypa/extensions/tools.ts index 7c09db0..3dec4cc 100644 --- a/packages/pi-hypa/extensions/tools.ts +++ b/packages/pi-hypa/extensions/tools.ts @@ -187,13 +187,18 @@ function normalizePathArg(path: string): string { } export function buildReadCommand(path: string, offset?: number, limit?: number): string { - const quotedPath = shellQuote(normalizePathArg(path)); + const normalized = normalizePathArg(path); if (offset !== undefined || limit !== undefined) { const start = Math.max(1, Math.floor(offset ?? 1)); const end = limit !== undefined ? start + Math.max(1, Math.floor(limit)) - 1 : "$"; - return `sed -n ${shellQuote(`${start},${end}p`)} -- ${quotedPath}`; + // BSD sed has no `--` end-of-options (macOS treats `--` as a filename). + // cat/grep/ls keep `--`; find is separate. Leading-dash relative paths get a + // `./` prefix so sed does not parse them as options (shell quoting alone does + // not help argv flags). + const sedPath = normalized.startsWith("-") ? `./${normalized}` : normalized; + return `sed -n ${shellQuote(`${start},${end}p`)} ${shellQuote(sedPath)}`; } - return `cat -- ${quotedPath}`; + return `cat -- ${shellQuote(normalized)}`; } /** diff --git a/packages/pi-hypa/test/tools.test.ts b/packages/pi-hypa/test/tools.test.ts index dc54019..898d2f8 100644 --- a/packages/pi-hypa/test/tools.test.ts +++ b/packages/pi-hypa/test/tools.test.ts @@ -35,8 +35,28 @@ test("shellQuote uses cmd-style double quotes on Windows", () => { }); test("buildReadCommand uses cat by default and sed for line slices", () => { + const home = homedir(); assert.equal(buildReadCommand("src/File.cs"), "cat -- src/File.cs"); - assert.equal(buildReadCommand("src/File.cs", 10, 5), "sed -n 10,14p -- src/File.cs"); + // offset + limit + assert.equal(buildReadCommand("src/File.cs", 10, 5), "sed -n 10,14p src/File.cs"); + // offset only → end is $ (shellQuote wraps the range because $ is not safe-unquoted) + assert.equal(buildReadCommand("src/File.cs", 10), `sed -n ${shellQuote("10,$p")} src/File.cs`); + // limit only → start defaults to 1 + assert.equal(buildReadCommand("src/File.cs", undefined, 5), "sed -n 1,5p src/File.cs"); + // path with spaces requires quoting (shellQuote is platform-aware) + assert.equal( + buildReadCommand("src/My File.cs", 10, 5), + `sed -n 10,14p ${shellQuote("src/My File.cs")}`, + ); + // leading-dash relative path: BSD sed has no `--`, so prefix ./ + assert.equal(buildReadCommand("-report.txt", 1, 5), "sed -n 1,5p ./-report.txt"); + // absolute paths starting with / need no ./ prefix + assert.equal(buildReadCommand("/tmp/-report.txt", 1, 5), "sed -n 1,5p /tmp/-report.txt"); + // tilde expands on the sed branch too + assert.equal( + buildReadCommand("~/notes.txt", 2, 3), + `sed -n 2,4p ${shellQuote(`${home}/notes.txt`)}`, + ); }); test("buildGrepCommand includes safe ripgrep options", () => { diff --git a/src/Hypa.Infrastructure/ProjectRoot/GitProjectRootDetector.cs b/src/Hypa.Infrastructure/ProjectRoot/GitProjectRootDetector.cs index d2d6d43..249c30d 100644 --- a/src/Hypa.Infrastructure/ProjectRoot/GitProjectRootDetector.cs +++ b/src/Hypa.Infrastructure/ProjectRoot/GitProjectRootDetector.cs @@ -18,7 +18,10 @@ public sealed class GitProjectRootDetector : IProjectRootDetector private static bool HasMarker(DirectoryInfo dir) { - if (Directory.Exists(Path.Combine(dir.FullName, ".git"))) return true; + // Normal repos use a `.git` directory. Worktrees and submodules use a `.git` + // *file* (`gitdir: ...` / `gitdir: ../.git/modules/...`). Either form marks a root. + var gitPath = Path.Combine(dir.FullName, ".git"); + if (Directory.Exists(gitPath) || File.Exists(gitPath)) return true; if (Directory.Exists(Path.Combine(dir.FullName, ".hypa"))) return true; if (dir.GetFiles("*.sln").Length > 0) return true; if (dir.GetFiles("*.slnx").Length > 0) return true; diff --git a/tests/Hypa.UnitTests/Infrastructure/GitProjectRootDetectorTests.cs b/tests/Hypa.UnitTests/Infrastructure/GitProjectRootDetectorTests.cs index 8df597b..7e9e777 100644 --- a/tests/Hypa.UnitTests/Infrastructure/GitProjectRootDetectorTests.cs +++ b/tests/Hypa.UnitTests/Infrastructure/GitProjectRootDetectorTests.cs @@ -22,6 +22,49 @@ public void Detect_GitDir_ReturnsRoot() Assert.Equal(_tempDir, result); } + [Fact] + public void Detect_GitWorktreeFile_ReturnsRoot() + { + // Git worktrees place a `.git` file (not directory) with a gitdir: pointer. + File.WriteAllText(Path.Combine(_tempDir, ".git"), "gitdir: /some/path/.git/worktrees/feature\n"); + var nested = Path.Combine(_tempDir, "src", "nested"); + Directory.CreateDirectory(nested); + + var detector = new GitProjectRootDetector(); + Assert.Equal(_tempDir, detector.Detect(nested)); + // Starting at the worktree root itself also resolves. + Assert.Equal(_tempDir, detector.Detect(_tempDir)); + } + + [Fact] + public void Detect_GitSubmoduleFile_ReturnsRoot() + { + // Submodules also use a `.git` file pointing into the superproject modules dir. + File.WriteAllText(Path.Combine(_tempDir, ".git"), "gitdir: ../.git/modules/vendor/lib\n"); + var nested = Path.Combine(_tempDir, "src"); + Directory.CreateDirectory(nested); + + var result = new GitProjectRootDetector().Detect(nested); + + Assert.Equal(_tempDir, result); + } + + [Fact] + public void Detect_GitFile_NearestWinsOverParentMarker() + { + // Outer project has a non-git marker; nested worktree/submodule has a `.git` file. + File.WriteAllText(Path.Combine(_tempDir, "Outer.sln"), ""); + var child = Path.Combine(_tempDir, "vendor", "lib"); + Directory.CreateDirectory(child); + File.WriteAllText(Path.Combine(child, ".git"), "gitdir: ../../.git/modules/vendor/lib\n"); + var deep = Path.Combine(child, "src"); + Directory.CreateDirectory(deep); + + var result = new GitProjectRootDetector().Detect(deep); + + Assert.Equal(child, result); + } + [Fact] public void Detect_SlnFile_ReturnsRoot() {