Skip to content

Fix worktree detection for worktrees inside .git directory #154

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 3 commits into from
Jun 19, 2025
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
51 changes: 41 additions & 10 deletions denops/gin/git/finder.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { resolve, SEPARATOR } from "jsr:@std/path@^1.0.0";
import { resolve } from "jsr:@std/path@^1.0.0";
import { Cache } from "jsr:@lambdalisue/ttl-cache@^1.0.0";
import { execute } from "./process.ts";
import { decodeUtf8 } from "../util/text.ts";
Expand All @@ -7,6 +7,17 @@ const ttl = 30000; // seconds
const cacheWorktree = new Cache<string, string | Error>(ttl);
const cacheGitdir = new Cache<string, string | Error>(ttl);

/**
* Check if we've reached the filesystem root by comparing a path with its parent.
* This works cross-platform (Windows, Linux, Mac).
*
* @param currentPath - The path to check
* @returns true if the path is the filesystem root, false otherwise
*/
function isFilesystemRoot(currentPath: string): boolean {
return currentPath === resolve(currentPath, "..");
}

/**
* Find a root path of a git working directory.
*
Expand All @@ -18,12 +29,24 @@ export async function findWorktree(cwd: string): Promise<string> {
const result = cacheWorktree.get(path) ?? await (async () => {
let result: string | Error;
try {
// Search upward from current directory to find worktree root
let currentPath = path;
while (!isFilesystemRoot(currentPath)) {
if (await isWorktreeRoot(currentPath)) {
result = currentPath;
cacheWorktree.set(path, result);
return result;
}
currentPath = resolve(currentPath, "..");
}

// If no worktree found, use normal detection for regular repositories
result = await revParse(path, [
"--show-toplevel",
"--show-superproject-working-tree",
]);
} catch (e) {
result = e;
result = e as Error;
}
cacheWorktree.set(path, result);
return result;
Expand All @@ -47,7 +70,7 @@ export async function findGitdir(cwd: string): Promise<string> {
try {
result = await revParse(path, ["--git-dir"]);
} catch (e) {
result = e;
result = e as Error;
}
cacheGitdir.set(path, result);
return result;
Expand All @@ -58,14 +81,22 @@ export async function findGitdir(cwd: string): Promise<string> {
return result;
}

async function revParse(cwd: string, args: string[]): Promise<string> {
const terms = cwd.split(SEPARATOR);
if (terms.includes(".git")) {
// `git rev-parse` does not work in a ".git" directory
// so use a parent directory of it instead.
const index = terms.indexOf(".git");
cwd = terms.slice(0, index).join(SEPARATOR);
async function isWorktreeRoot(currentPath: string): Promise<boolean> {
try {
const gitPath = resolve(currentPath, ".git");
const stat = await Deno.stat(gitPath);
if (stat.isFile) {
// Found a .git file, verify it's a valid git worktree
await revParse(currentPath, ["--git-dir"]);
return true;
}
} catch {
// Either .git doesn't exist or it's not a valid worktree
}
return false;
}

async function revParse(cwd: string, args: string[]): Promise<string> {
const stdout = await execute(["rev-parse", ...args], { cwd });
const output = decodeUtf8(stdout);
return resolve(output.split(/\n/, 2)[0]);
Expand Down
55 changes: 55 additions & 0 deletions denops/gin/git/finder_test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,61 @@ Deno.test({
sanitizeOps: false,
});

Deno.test({
name: "findWorktree() returns a root path for worktree inside .git directory",
fn: async () => {
await using sbox = await prepare();
// Create a worktree inside .git/workspaces/ directory
await Deno.mkdir(join(".git", "workspaces"), { recursive: true });
await $`git worktree add -b workspace-test .git/workspaces/test main`;

// Change to a subdirectory within the worktree
await Deno.mkdir(join(".git", "workspaces", "test", "subdir"), {
recursive: true,
});
Deno.chdir(join(".git", "workspaces", "test", "subdir"));

// findWorktree should return the worktree root, not the main repository root
assertEquals(
await findWorktree("."),
join(sbox.path, ".git", "workspaces", "test"),
);
// An internal cache will be used for the following call
assertEquals(
await findWorktree("."),
join(sbox.path, ".git", "workspaces", "test"),
);
},
sanitizeResources: false,
sanitizeOps: false,
});

Deno.test({
name: "findGitdir() returns a gitdir path for worktree inside .git directory",
fn: async () => {
await using sbox = await prepare();
// Create a worktree inside .git/workspaces/ directory
await Deno.mkdir(join(".git", "workspaces"), { recursive: true });
await $`git worktree add -b workspace-test2 .git/workspaces/test2 main`;

// Change to the worktree
Deno.chdir(join(".git", "workspaces", "test2"));

// findGitdir should return the correct gitdir for this worktree
assertEquals(
await findGitdir("."),
join(sbox.path, ".git", "worktrees", "test2"),
);
// An internal cache will be used for the following call
assertEquals(
await findGitdir("."),
join(sbox.path, ".git", "worktrees", "test2"),
);
},
sanitizeResources: false,
sanitizeOps: false,
});

Deno.test({
name:
"findGitdir() throws an error if the path is not in a git working directory",
Expand Down