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
86 changes: 69 additions & 17 deletions src/main/lib/git/git-operations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,27 +71,79 @@ export const createGitOperationsRouter = () => {
z.object({
worktreePath: z.string(),
branch: z.string(),
/** "auto-stash" stashes uncommitted changes and pops them on the
* new branch; "carry" attempts a plain checkout which git will
* allow only if there's no conflict; default aborts if dirty. */
uncommittedStrategy: z
.enum(["abort", "carry", "stash"])
.optional()
.default("abort"),
}),
)
.mutation(async ({ input }): Promise<{ success: boolean }> => {
assertRegisteredWorktree(input.worktreePath);
.mutation(
async ({
input,
}): Promise<{ success: boolean; stashPopFailed?: boolean }> => {
assertRegisteredWorktree(input.worktreePath);

return withGitLock(input.worktreePath, async () => {
// Check for uncommitted changes before checkout
if (await hasUncommittedChanges(input.worktreePath)) {
throw new Error(
"Cannot switch branches: you have uncommitted changes. Please commit or stash your changes first."
);
}
return withGitLock(input.worktreePath, async () => {
const dirty = await hasUncommittedChanges(input.worktreePath);

const git = createGit(input.worktreePath);
await withLockRetry(input.worktreePath, () =>
git.checkout(input.branch)
);
invalidateGitStateCaches(input.worktreePath);
return { success: true };
});
}),
if (dirty && input.uncommittedStrategy === "abort") {
throw new Error(
"Cannot switch branches: you have uncommitted changes. Please commit or stash your changes first."
);
}

const git = createGit(input.worktreePath);

if (dirty && input.uncommittedStrategy === "stash") {
await withLockRetry(input.worktreePath, () =>
git.stash([
"push",
"-u",
"-m",
`Auto-stash before switching to ${input.branch}`,
]),
);
}

try {
await withLockRetry(input.worktreePath, () =>
git.checkout(input.branch)
);
} catch (checkoutError) {
// If we stashed, try to pop back so the user doesn't lose work
if (dirty && input.uncommittedStrategy === "stash") {
try {
await git.stash(["pop"]);
} catch {
const msg =
checkoutError instanceof Error
? checkoutError.message
: "Checkout failed";
throw new Error(
`${msg}. Your uncommitted changes are saved in git stash — run 'git stash pop' manually to restore them.`,
);
}
}
throw checkoutError;
}

let stashPopFailed = false;
if (dirty && input.uncommittedStrategy === "stash") {
try {
await git.stash(["pop"]);
} catch {
stashPopFailed = true;
}
}

invalidateGitStateCaches(input.worktreePath);
return { success: true, stashPopFailed };
});
},
),

getHistory: publicProcedure
.input(
Expand Down
159 changes: 159 additions & 0 deletions src/main/lib/git/github/github.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,10 @@ import {
type CheckItem,
type GHPRResponse,
type GitHubStatus,
type PRComment,
GHPRResponseSchema,
GHRepoResponseSchema,
GHReviewCommentSchema,
} from "./types";

const execFileAsync = promisify(execFile);
Expand All @@ -16,6 +18,18 @@ const execFileAsync = promisify(execFile);
const cache = new Map<string, { data: GitHubStatus; timestamp: number }>();
const CACHE_TTL_MS = 10_000;

/**
* Drop cached PR status for a worktree so the next fetch hits the real gh CLI.
* Call this after a mutation that changes PR state (title rename, merge, etc.).
*/
export function invalidateGitHubPRCache(worktreePath?: string): void {
if (worktreePath) {
cache.delete(worktreePath);
} else {
cache.clear();
}
}

/**
* Fetches GitHub PR status for a worktree using the `gh` CLI.
* Returns null if `gh` is not installed, not authenticated, or on error.
Expand Down Expand Up @@ -228,3 +242,148 @@ function computeChecksStatus(
if (hasPending) return "pending";
return "success";
}

// Cache for PR comments (30 second TTL — comments change less often than status)
const commentsCache = new Map<
string,
{ data: PRComment[]; timestamp: number }
>();
const COMMENTS_CACHE_TTL_MS = 30_000;

/**
* Fetch both general (issue) and review (code-level) comments for the current
* branch's PR. Returns an empty array when there's no PR or gh can't reach it.
* Cached for 30 seconds per worktree.
*/
export async function fetchGitHubPRComments(
worktreePath: string,
): Promise<PRComment[]> {
const cached = commentsCache.get(worktreePath);
if (cached && Date.now() - cached.timestamp < COMMENTS_CACHE_TTL_MS) {
return cached.data;
}

try {
const { stdout: branchOutput } = await execFileAsync(
"git",
["rev-parse", "--abbrev-ref", "HEAD"],
{ cwd: worktreePath },
);
const branchName = branchOutput.trim();
if (!branchName) return [];

let prNumber: number | null = null;
try {
const { stdout } = await execWithShellEnv(
"gh",
["pr", "view", branchName, "--json", "number"],
{ cwd: worktreePath },
);
const parsed = JSON.parse(stdout);
if (typeof parsed?.number === "number") {
prNumber = parsed.number;
}
} catch {
return [];
}
if (!prNumber) return [];

const [issueStdout, reviewStdout] = await Promise.all([
execWithShellEnv(
"gh",
["pr", "view", String(prNumber), "--json", "comments"],
{ cwd: worktreePath },
)
.then((r) => r.stdout)
.catch(() => null),
execWithShellEnv(
"gh",
[
"api",
`repos/{owner}/{repo}/pulls/${prNumber}/comments`,
"--paginate",
],
{ cwd: worktreePath },
)
.then((r) => r.stdout)
.catch(() => null),
]);

const comments: PRComment[] = [];

if (issueStdout) {
try {
const raw = JSON.parse(issueStdout);
const rawComments = Array.isArray(raw?.comments) ? raw.comments : [];
for (const c of rawComments) {
const login = c?.author?.login ?? "unknown";
const createdAt = c?.createdAt ?? c?.created_at ?? null;
const body = typeof c?.body === "string" ? c.body : "";
if (!createdAt) continue;
comments.push({
id: typeof c?.id === "number" ? c.id : comments.length,
kind: "issue",
author: login,
avatarUrl: null,
createdAt,
body,
htmlUrl: c?.url ?? null,
});
}
} catch (err) {
console.error("[GitHub] Failed to parse issue comments:", err);
}
}

if (reviewStdout) {
try {
const raw = JSON.parse(reviewStdout);
const arr = Array.isArray(raw) ? raw : [];
for (const c of arr) {
const parsed = GHReviewCommentSchema.safeParse(c);
if (!parsed.success) continue;
const data = parsed.data;
comments.push({
id: data.id,
kind: "review",
author: data.user?.login ?? "unknown",
avatarUrl: data.user?.avatar_url ?? null,
createdAt: data.created_at,
body: data.body ?? "",
htmlUrl: data.html_url ?? null,
path: data.path ?? null,
line: data.line ?? data.original_line ?? null,
diffHunk: data.diff_hunk ?? null,
});
}
} catch (err) {
console.error("[GitHub] Failed to parse review comments:", err);
}
}

comments.sort(
(a, b) =>
new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime(),
);

commentsCache.set(worktreePath, {
data: comments,
timestamp: Date.now(),
});
return comments;
} catch (err) {
console.error("[GitHub] fetchGitHubPRComments failed:", err);
return [];
}
}

/**
* Invalidate the PR comments cache for a worktree.
*/
export function invalidateGitHubPRCommentsCache(worktreePath?: string): void {
if (worktreePath) {
commentsCache.delete(worktreePath);
} else {
commentsCache.clear();
}
}
9 changes: 7 additions & 2 deletions src/main/lib/git/github/index.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,7 @@
export { fetchGitHubPRStatus } from "./github";
export type { CheckItem, GitHubStatus, MergeableStatus } from "./types";
export {
fetchGitHubPRStatus,
fetchGitHubPRComments,
invalidateGitHubPRCache,
invalidateGitHubPRCommentsCache,
} from "./github";
export type { CheckItem, GitHubStatus, MergeableStatus, PRComment } from "./types";
39 changes: 39 additions & 0 deletions src/main/lib/git/github/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,45 @@ export const GHRepoResponseSchema = z.object({
url: z.string(),
});

/** Issue comment on a PR (from GET /repos/{owner}/{repo}/issues/{n}/comments) */
export const GHIssueCommentSchema = z.object({
id: z.number(),
body: z.string().nullable(),
created_at: z.string(),
updated_at: z.string().nullable().optional(),
html_url: z.string().nullable().optional(),
user: z
.object({
login: z.string(),
avatar_url: z.string().nullable().optional(),
})
.nullable()
.optional(),
});

/** Review comment on a PR (from GET /repos/{owner}/{repo}/pulls/{n}/comments) */
export const GHReviewCommentSchema = GHIssueCommentSchema.extend({
path: z.string().nullable().optional(),
line: z.number().nullable().optional(),
original_line: z.number().nullable().optional(),
diff_hunk: z.string().nullable().optional(),
position: z.number().nullable().optional(),
});

/** Unified comment type returned from the backend to the renderer */
export interface PRComment {
id: number;
kind: "issue" | "review";
author: string;
avatarUrl?: string | null;
createdAt: string;
body: string;
htmlUrl?: string | null;
path?: string | null;
line?: number | null;
diffHunk?: string | null;
}

export type GHPRResponse = z.infer<typeof GHPRResponseSchema>;

/** Single CI/CD check item */
Expand Down
5 changes: 5 additions & 0 deletions src/main/lib/terminal/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,11 @@ export async function createSession(
} = params

const shell = useFallbackShell ? FALLBACK_SHELL : getDefaultShell()
if (!cwd) {
console.warn(
`[Terminal] No cwd provided for paneId=${paneId} — falling back to ${os.homedir()}. This usually means the workspace path wasn't ready when the terminal was created.`,
)
}
const workingDir = validateAndResolveCwd(cwd || os.homedir())
const terminalCols = cols || DEFAULT_COLS
const terminalRows = rows || DEFAULT_ROWS
Expand Down
Loading