Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 8 additions & 3 deletions packages/pi-hypa/extensions/tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)}`;
}

/**
Expand Down
22 changes: 21 additions & 1 deletion packages/pi-hypa/test/tools.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
43 changes: 43 additions & 0 deletions tests/Hypa.UnitTests/Infrastructure/GitProjectRootDetectorTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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()
{
Expand Down
Loading