Commit 8d665451e1bb

Vincent Demeester <vincent@sbr.pm>
2026-02-11 13:45:35
fix: GitHub extension detects git repo root
GitHub extension now automatically detects and uses the git repository root directory for all gh CLI commands. This fixes 'not a git repository' errors that occurred when pi started in a non-git directory, even after navigating to a repo.
1 parent 1c5d2a5
Changed files (6)
dots/pi/agent/extensions/github/actions/checks.ts
@@ -4,7 +4,7 @@
 
 import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent";
 import type { GhDetails } from "../types";
-import { parseChecks, parseRunList, getErrorMessage, getCheckIcon, getRunStatusIcon } from "../utils";
+import { parseChecks, parseRunList, getErrorMessage, getCheckIcon, getRunStatusIcon, execGh } from "../utils";
 
 /**
  * Show check status for a PR
@@ -26,8 +26,7 @@ export async function handleChecks(
 
 	onUpdate?.({ content: [{ type: "text", text: `Fetching checks for PR #${params.number}...` }] });
 
-	const result = await pi.exec(
-		"gh",
+	const result = await execGh(pi, ctx,
 		["pr", "view", String(params.number), "--json", "statusCheckRollup"],
 		{ signal, timeout: 30000 },
 	);
@@ -95,7 +94,7 @@ export async function handleChecksLog(
 
 	onUpdate?.({ content: [{ type: "text", text: `Fetching logs for run ${params.runId}...` }] });
 
-	const result = await pi.exec("gh", ["run", "view", String(params.runId), "--log-failed"], {
+	const result = await execGh(pi, ctx, ["run", "view", String(params.runId), "--log-failed"], {
 		signal,
 		timeout: 60000,
 	});
@@ -166,7 +165,7 @@ export async function handleChecksRestart(
 
 	onUpdate?.({ content: [{ type: "text", text: `Restarting run ${params.runId}...` }] });
 
-	const result = await pi.exec("gh", args, { signal, timeout: 30000 });
+	const result = await execGh(pi, ctx, args, { signal, timeout: 30000 });
 
 	if (result.code !== 0) {
 		return {
@@ -207,7 +206,7 @@ export async function handleRunList(
 
 	onUpdate?.({ content: [{ type: "text", text: "Fetching workflow runs..." }] });
 
-	const result = await pi.exec("gh", args, { signal, timeout: 30000 });
+	const result = await execGh(pi, ctx, args, { signal, timeout: 30000 });
 
 	if (result.code !== 0) {
 		return {
@@ -262,8 +261,7 @@ export async function handleRunView(
 
 	onUpdate?.({ content: [{ type: "text", text: `Fetching run ${params.runId}...` }] });
 
-	const result = await pi.exec(
-		"gh",
+	const result = await execGh(pi, ctx,
 		[
 			"run",
 			"view",
dots/pi/agent/extensions/github/actions/issue.ts
@@ -12,6 +12,7 @@ import {
 	buildIssueCreateConfirmation,
 	buildCommentConfirmation,
 	truncate,
+	execGh,
 } from "../utils";
 
 const ISSUE_LIST_FIELDS = "number,title,state,author,url,labels,assignees,createdAt,updatedAt,body,comments";
@@ -40,7 +41,7 @@ export async function handleIssueList(
 
 	onUpdate?.({ content: [{ type: "text", text: "Fetching issues..." }] });
 
-	const result = await pi.exec("gh", args, { signal, timeout: 30000 });
+	const result = await execGh(pi, ctx, args, { signal, timeout: 30000 });
 
 	if (result.code !== 0) {
 		return {
@@ -93,8 +94,7 @@ export async function handleIssueView(
 
 	onUpdate?.({ content: [{ type: "text", text: `Fetching issue #${params.number}...` }] });
 
-	const result = await pi.exec(
-		"gh",
+	const result = await execGh(pi, ctx,
 		["issue", "view", String(params.number), "--json", `${ISSUE_LIST_FIELDS},milestone`],
 		{ signal, timeout: 20000 },
 	);
@@ -142,8 +142,7 @@ export async function handleIssueView(
 	if (commentsCount > 0) {
 		output += `\n## Comments (${commentsCount})\n`;
 		// Fetch comments separately via gh issue view --comments
-		const commentsResult = await pi.exec(
-			"gh",
+		const commentsResult = await execGh(pi, ctx,
 			["issue", "view", String(params.number), "--comments", "--json", "comments"],
 			{ signal, timeout: 20000 },
 		);
@@ -215,7 +214,7 @@ export async function handleIssueCreate(
 
 	onUpdate?.({ content: [{ type: "text", text: "Creating issue..." }] });
 
-	const result = await pi.exec("gh", args, { signal, timeout: 30000 });
+	const result = await execGh(pi, ctx, args, { signal, timeout: 30000 });
 
 	if (result.code !== 0) {
 		return {
@@ -276,7 +275,7 @@ export async function handleIssueClose(
 	const args = ["issue", "close", String(params.number)];
 	if (params.reason) args.push("--reason", params.reason);
 
-	const result = await pi.exec("gh", args, { signal, timeout: 20000 });
+	const result = await execGh(pi, ctx, args, { signal, timeout: 20000 });
 
 	if (result.code !== 0) {
 		return {
@@ -333,8 +332,7 @@ export async function handleIssueComment(
 
 	onUpdate?.({ content: [{ type: "text", text: `Adding comment to issue #${params.number}...` }] });
 
-	const result = await pi.exec(
-		"gh",
+	const result = await execGh(pi, ctx,
 		["issue", "comment", String(params.number), "--body", params.body],
 		{ signal, timeout: 20000 },
 	);
@@ -422,7 +420,7 @@ export async function handleIssueEdit(
 
 	onUpdate?.({ content: [{ type: "text", text: `Editing issue #${params.number}...` }] });
 
-	const result = await pi.exec("gh", args, { signal, timeout: 20000 });
+	const result = await execGh(pi, ctx, args, { signal, timeout: 20000 });
 
 	if (result.code !== 0) {
 		return {
dots/pi/agent/extensions/github/actions/pr.ts
@@ -24,6 +24,7 @@ import {
 	truncate,
 	getReviewDecisionText,
 	formatRelativeDate,
+	execGh,
 } from "../utils";
 
 const PR_LIST_FIELDS = "number,title,state,author,headRefName,baseRefName,url,isDraft,labels,reviewDecision,additions,deletions,changedFiles,createdAt,updatedAt";
@@ -52,7 +53,7 @@ export async function handlePRList(
 
 	onUpdate?.({ content: [{ type: "text", text: "Fetching PRs..." }] });
 
-	const result = await pi.exec("gh", args, { signal, timeout: 30000 });
+	const result = await execGh(pi, ctx, args, { signal, timeout: 30000 });
 
 	if (result.code !== 0) {
 		return {
@@ -107,7 +108,7 @@ export async function handlePRView(
 	onUpdate?.({ content: [{ type: "text", text: `Fetching PR #${params.number}...` }] });
 
 	const fields = `${PR_LIST_FIELDS},body,mergeStateStatus,statusCheckRollup,reviews,comments`;
-	const result = await pi.exec("gh", ["pr", "view", String(params.number), "--json", fields], {
+	const result = await execGh(pi, ctx, ["pr", "view", String(params.number), "--json", fields], {
 		signal,
 		timeout: 30000,
 	});
@@ -200,7 +201,7 @@ export async function handlePRDiff(
 
 	onUpdate?.({ content: [{ type: "text", text: `Fetching diff for PR #${params.number}...` }] });
 
-	const result = await pi.exec("gh", ["pr", "diff", String(params.number)], { signal, timeout: 30000 });
+	const result = await execGh(pi, ctx, ["pr", "diff", String(params.number)], { signal, timeout: 30000 });
 
 	if (result.code !== 0) {
 		return {
@@ -266,7 +267,7 @@ export async function handlePRCreate(
 
 	onUpdate?.({ content: [{ type: "text", text: "Creating PR..." }] });
 
-	const result = await pi.exec("gh", args, { signal, timeout: 30000 });
+	const result = await execGh(pi, ctx, args, { signal, timeout: 30000 });
 
 	if (result.code !== 0) {
 		return {
@@ -305,7 +306,7 @@ export async function handlePRCheckout(
 
 	onUpdate?.({ content: [{ type: "text", text: `Checking out PR #${params.number}...` }] });
 
-	const result = await pi.exec("gh", ["pr", "checkout", String(params.number)], { signal, timeout: 30000 });
+	const result = await execGh(pi, ctx, ["pr", "checkout", String(params.number)], { signal, timeout: 30000 });
 
 	if (result.code !== 0) {
 		return {
@@ -363,7 +364,7 @@ export async function handlePRMerge(
 
 	onUpdate?.({ content: [{ type: "text", text: `Merging PR #${params.number}...` }] });
 
-	const result = await pi.exec("gh", args, { signal, timeout: 30000 });
+	const result = await execGh(pi, ctx, args, { signal, timeout: 30000 });
 
 	if (result.code !== 0) {
 		return {
@@ -436,7 +437,7 @@ export async function handlePRReview(
 
 	onUpdate?.({ content: [{ type: "text", text: `Submitting review on PR #${params.number}...` }] });
 
-	const result = await pi.exec("gh", args, { signal, timeout: 30000 });
+	const result = await execGh(pi, ctx, args, { signal, timeout: 30000 });
 
 	if (result.code !== 0) {
 		return {
@@ -498,7 +499,7 @@ export async function handlePRComment(
 
 	onUpdate?.({ content: [{ type: "text", text: `Adding comment to PR #${params.number}...` }] });
 
-	const result = await pi.exec("gh", ["pr", "comment", String(params.number), "--body", params.body], {
+	const result = await execGh(pi, ctx, ["pr", "comment", String(params.number), "--body", params.body], {
 		signal,
 		timeout: 20000,
 	});
@@ -556,7 +557,7 @@ export async function handlePRReady(
 
 	onUpdate?.({ content: [{ type: "text", text: `Marking PR #${params.number} as ready...` }] });
 
-	const result = await pi.exec("gh", ["pr", "ready", String(params.number)], { signal, timeout: 20000 });
+	const result = await execGh(pi, ctx, ["pr", "ready", String(params.number)], { signal, timeout: 20000 });
 
 	if (result.code !== 0) {
 		return {
@@ -609,7 +610,7 @@ export async function handlePRClose(
 		}
 	}
 
-	const result = await pi.exec("gh", ["pr", "close", String(params.number)], { signal, timeout: 20000 });
+	const result = await execGh(pi, ctx, ["pr", "close", String(params.number)], { signal, timeout: 20000 });
 
 	if (result.code !== 0) {
 		return {
@@ -633,8 +634,7 @@ async function getPRTitle(
 	prNumber: number,
 	signal?: AbortSignal,
 ): Promise<string | null> {
-	const result = await pi.exec(
-		"gh",
+	const result = await execGh(pi, ctx,
 		["pr", "view", String(prNumber), "--json", "title", "--jq", ".title"],
 		{ signal, timeout: 15000 },
 	);
@@ -650,8 +650,7 @@ async function getPRHeadSha(
 	prNumber: number,
 	signal?: AbortSignal,
 ): Promise<string | null> {
-	const result = await pi.exec(
-		"gh",
+	const result = await execGh(pi, ctx,
 		["pr", "view", String(prNumber), "--json", "headRefOid", "--jq", ".headRefOid"],
 		{ signal, timeout: 15000 },
 	);
@@ -744,8 +743,7 @@ export async function handlePRLineComment(
 	const tmpFile = `/tmp/gh-line-comment-${Date.now()}.json`;
 	await pi.exec("sh", ["-c", `cat > ${tmpFile} << 'GHEOF'\n${JSON.stringify(payload)}\nGHEOF`], { signal });
 
-	const result = await pi.exec(
-		"gh",
+	const result = await execGh(pi, ctx,
 		[
 			"api",
 			"repos/{owner}/{repo}/pulls/" + params.number + "/comments",
@@ -877,8 +875,7 @@ export async function handlePRReviewWithComments(
 	const tmpFile = `/tmp/gh-review-${Date.now()}.json`;
 	await pi.exec("sh", ["-c", `cat > ${tmpFile} << 'GHEOF'\n${JSON.stringify(payload)}\nGHEOF`], { signal });
 
-	const result = await pi.exec(
-		"gh",
+	const result = await execGh(pi, ctx,
 		[
 			"api",
 			"repos/{owner}/{repo}/pulls/" + params.number + "/reviews",
@@ -943,8 +940,7 @@ export async function handlePRReviewsList(
 
 	onUpdate?.({ content: [{ type: "text", text: `Fetching reviews for PR #${params.number}...` }] });
 
-	const result = await pi.exec(
-		"gh",
+	const result = await execGh(pi, ctx,
 		["api", `repos/{owner}/{repo}/pulls/${params.number}/reviews`, "--paginate"],
 		{ signal, timeout: 15000 },
 	);
@@ -1031,8 +1027,7 @@ export async function handlePRReviewEdit(
 	const tmpFile = `/tmp/gh-review-edit-${Date.now()}.json`;
 	await pi.exec("sh", ["-c", `cat > ${tmpFile} << 'GHEOF'\n${payload}\nGHEOF`], { signal });
 
-	const result = await pi.exec(
-		"gh",
+	const result = await execGh(pi, ctx,
 		[
 			"api",
 			`repos/{owner}/{repo}/pulls/${params.number}/reviews/${params.reviewId}`,
@@ -1089,8 +1084,7 @@ export async function handlePRReviewCommentsList(
 
 	onUpdate?.({ content: [{ type: "text", text: `Fetching review comments for PR #${params.number}...` }] });
 
-	const result = await pi.exec(
-		"gh",
+	const result = await execGh(pi, ctx,
 		["api", `repos/{owner}/{repo}/pulls/${params.number}/comments`, "--paginate"],
 		{ signal, timeout: 15000 },
 	);
@@ -1171,8 +1165,7 @@ export async function handlePRReviewCommentEdit(
 	const tmpFile = `/tmp/gh-comment-edit-${Date.now()}.json`;
 	await pi.exec("sh", ["-c", `cat > ${tmpFile} << 'GHEOF'\n${payload}\nGHEOF`], { signal });
 
-	const result = await pi.exec(
-		"gh",
+	const result = await execGh(pi, ctx,
 		[
 			"api",
 			`repos/{owner}/{repo}/pulls/comments/${params.commentId}`,
@@ -1238,8 +1231,7 @@ export async function handlePRReviewCommentDelete(
 
 	onUpdate?.({ content: [{ type: "text", text: `Deleting comment ${params.commentId}...` }] });
 
-	const result = await pi.exec(
-		"gh",
+	const result = await execGh(pi, ctx,
 		[
 			"api",
 			`repos/{owner}/{repo}/pulls/comments/${params.commentId}`,
dots/pi/agent/extensions/github/actions/repo.ts
@@ -4,7 +4,7 @@
 
 import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent";
 import type { GhDetails } from "../types";
-import { parseRepo, parseReleaseList, getErrorMessage, formatRelativeDate } from "../utils";
+import { parseRepo, parseReleaseList, getErrorMessage, formatRelativeDate, execGh } from "../utils";
 
 /**
  * View repository info
@@ -18,8 +18,7 @@ export async function handleRepoView(
 ): Promise<any> {
 	onUpdate?.({ content: [{ type: "text", text: "Fetching repo info..." }] });
 
-	const result = await pi.exec(
-		"gh",
+	const result = await execGh(pi, ctx,
 		[
 			"repo",
 			"view",
@@ -82,7 +81,7 @@ export async function handleReleaseList(
 
 	onUpdate?.({ content: [{ type: "text", text: "Fetching releases..." }] });
 
-	const result = await pi.exec("gh", args, { signal, timeout: 20000 });
+	const result = await execGh(pi, ctx, args, { signal, timeout: 20000 });
 
 	if (result.code !== 0) {
 		return {
dots/pi/agent/extensions/github/index.ts
@@ -65,6 +65,8 @@ import {
 	getRunStatusIcon,
 	getReviewDecisionText,
 	formatRelativeDate,
+	resetGitRoot,
+	execGh,
 } from "./utils";
 
 export default function (pi: ExtensionAPI) {
@@ -80,6 +82,7 @@ export default function (pi: ExtensionAPI) {
 		currentUser = "";
 		recentPRs = [];
 		recentIssues = [];
+		resetGitRoot(); // Reset git root detection on session change
 
 		for (const entry of ctx.sessionManager.getBranch()) {
 			if (entry.type !== "message") continue;
@@ -125,9 +128,9 @@ export default function (pi: ExtensionAPI) {
 	pi.on("session_tree", async (_event, ctx) => reconstructState(ctx));
 
 	// Helper: fetch current user lazily
-	async function ensureCurrentUser(signal?: AbortSignal): Promise<string> {
+	async function ensureCurrentUser(ctx: ExtensionContext, signal?: AbortSignal): Promise<string> {
 		if (currentUser) return currentUser;
-		const result = await pi.exec("gh", ["api", "user", "--jq", ".login"], { signal, timeout: 10000 });
+		const result = await execGh(pi, ctx, ["api", "user", "--jq", ".login"], { signal, timeout: 10000 });
 		if (result.code === 0) {
 			currentUser = result.stdout.trim();
 		}
@@ -513,9 +516,10 @@ export default function (pi: ExtensionAPI) {
 				return;
 			}
 
-			const user = await ensureCurrentUser();
-			const result = await pi.exec(
-				"gh",
+			const user = await ensureCurrentUser(ctx);
+			const result = await execGh(
+				pi,
+				ctx,
 				[
 					"pr",
 					"list",
@@ -578,8 +582,9 @@ export default function (pi: ExtensionAPI) {
 				return;
 			}
 
-			const result = await pi.exec(
-				"gh",
+			const result = await execGh(
+				pi,
+				ctx,
 				[
 					"pr",
 					"list",
@@ -658,8 +663,9 @@ export default function (pi: ExtensionAPI) {
 				return;
 			}
 
-			const result = await pi.exec(
-				"gh",
+			const result = await execGh(
+				pi,
+				ctx,
 				["pr", "view", String(number), "--json", "number,title,state,author,headRefName,baseRefName,isDraft,url,reviewDecision,additions,deletions,changedFiles,body,statusCheckRollup"],
 				{ timeout: 30000 },
 			);
@@ -740,8 +746,9 @@ export default function (pi: ExtensionAPI) {
 				return;
 			}
 
-			const result = await pi.exec(
-				"gh",
+			const result = await execGh(
+				pi,
+				ctx,
 				["pr", "view", String(number), "--json", "statusCheckRollup"],
 				{ timeout: 30000 },
 			);
@@ -791,8 +798,9 @@ export default function (pi: ExtensionAPI) {
 				return;
 			}
 
-			const result = await pi.exec(
-				"gh",
+			const result = await execGh(
+				pi,
+				ctx,
 				["issue", "list", "--state", "open", "--json", "number,title,state,labels,assignees,url", "--limit", "20"],
 				{ timeout: 30000 },
 			);
@@ -845,8 +853,9 @@ export default function (pi: ExtensionAPI) {
 				return;
 			}
 
-			const result = await pi.exec(
-				"gh",
+			const result = await execGh(
+				pi,
+				ctx,
 				["run", "list", "--json", "databaseId,name,displayTitle,status,conclusion,headBranch,url,createdAt", "--limit", "15"],
 				{ timeout: 30000 },
 			);
dots/pi/agent/extensions/github/utils.ts
@@ -2,9 +2,56 @@
  * Utility functions for GitHub extension
  */
 
-import type { Theme } from "@mariozechner/pi-coding-agent";
+import type { Theme, ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent";
 import type { GhPR, GhIssue, GhCheck, GhRun, GhReview, GhReviewComment, GhReviewSummary, GhRelease, GhRepo } from "./types";
 
+// ============================================================================
+// Git Repository Detection (shared with handlers)
+// ============================================================================
+
+let cachedGitRoot: string | null = null;
+
+/**
+ * Find the git repository root directory.
+ * Returns null if not in a git repository.
+ */
+async function findGitRoot(pi: ExtensionAPI, cwd: string): Promise<string | null> {
+	if (cachedGitRoot !== null) return cachedGitRoot;
+	
+	try {
+		const result = await pi.exec("git", ["rev-parse", "--show-toplevel"], { cwd, timeout: 5000 });
+		if (result.code === 0 && result.stdout.trim()) {
+			cachedGitRoot = result.stdout.trim();
+			return cachedGitRoot;
+		}
+	} catch {
+		// Ignore errors
+	}
+	return null;
+}
+
+/**
+ * Reset cached git root (call on session change)
+ */
+export function resetGitRoot() {
+	cachedGitRoot = null;
+}
+
+/**
+ * Execute gh command with correct working directory.
+ * Automatically uses git repository root if available.
+ */
+export async function execGh(
+	pi: ExtensionAPI,
+	ctx: ExtensionContext,
+	args: string[],
+	options?: { signal?: AbortSignal; timeout?: number }
+) {
+	const root = await findGitRoot(pi, ctx.cwd);
+	const cwd = root ?? ctx.cwd;
+	return await pi.exec("gh", args, { ...options, cwd });
+}
+
 // ============================================================================
 // Parsing: gh CLI JSON output → typed objects
 // ============================================================================