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
136 changes: 37 additions & 99 deletions src/flows/LocalReview/LocalDiffPrompt.tsx
Original file line number Diff line number Diff line change
@@ -1,125 +1,63 @@
import React, { useState, useEffect } from "react";
import { Box, Text } from "ink";
import SelectInput from "ink-select-input";
import { ITEM_SELECTION_GAP, ITEM_SELECTOR } from "../../theme/symbols.js";
import {
hasStagedChanges,
getUnstagedFiles,
getStagedDiff,
getAllDiff,
} from "../../lib/git.js";

type DiffChoice = "staged" | "all";

interface SelectItem {
label: string;
value: DiffChoice;
import React, { useEffect } from "react";
import { getDiff, getBranchDiff, isOnNonDefaultBranch } from "../../lib/git.js";

export interface DiffResult {
diffText: string;
currentBranch: string | null;
defaultBranch: string | null;
hasUncommittedChanges: boolean;
}

interface LocalDiffPromptProps {
onReady: (diffText: string) => void;
onReady: (result: DiffResult) => void;
onError: (message: string) => void;
}

const LocalDiffPrompt: React.FC<LocalDiffPromptProps> = ({
onReady,
onError,
}) => {
const [unstagedFiles, setUnstagedFiles] = useState<string[]>([]);
const [hasStaged, setHasStaged] = useState(false);
const [ready, setReady] = useState(false);

useEffect(() => {
try {
const staged = hasStagedChanges();
const unstaged = getUnstagedFiles();
const info = isOnNonDefaultBranch();
const hasUncommitted = !!getDiff();

if (info.onFeatureBranch && info.defaultBranch) {
const diff = getBranchDiff(info.defaultBranch);
if (!diff) {
onError(
`No changes found between ${info.currentBranch} and ${info.defaultBranch}.`,
);
return;
}
onReady({
diffText: diff,
currentBranch: info.currentBranch,
defaultBranch: info.defaultBranch,
hasUncommittedChanges: hasUncommitted,
});
return;
}

if (!staged && unstaged.length === 0) {
// Default branch or detection failed — use uncommitted diff
if (!hasUncommitted) {
onError(
"No changes detected. Stage some changes with `git add` and try again.",
);
return;
}

setHasStaged(staged);
setUnstagedFiles(unstaged);

// Auto-proceed if no unstaged files
if (unstaged.length === 0) {
onReady(getStagedDiff());
return;
}

// If no staged changes but there are unstaged, auto-select all
if (!staged) {
onReady(getAllDiff());
return;
}

setReady(true);
onReady({
diffText: getDiff(),
currentBranch: null,
defaultBranch: null,
hasUncommittedChanges: false,
});
} catch (err) {
onError(err instanceof Error ? err.message : "Failed to read git state");
}
}, []);

if (!ready) {
return null;
}

const items: SelectItem[] = [
{ label: "Review only staged changes", value: "staged" },
{ label: "Review all changes (staged + unstaged)", value: "all" },
];

const handleSelect = (item: SelectItem) => {
if (item.value === "staged") {
onReady(getStagedDiff());
} else {
onReady(getAllDiff());
}
};

return (
<Box flexDirection="column">
<Box flexDirection="column" marginBottom={1}>
<Text color="yellow" bold>
The following files have unstaged changes:
</Text>
{unstagedFiles.map((file) => (
<Text key={file} color="yellow">
{" "}- {file}
</Text>
))}
</Box>

{hasStaged && (
<>
<Box marginBottom={1}>
<Text>How would you like to proceed?</Text>
</Box>

<SelectInput
items={items}
onSelect={handleSelect}
indicatorComponent={({ isSelected }) => (
<Text color={isSelected ? "green" : "gray"}>
{isSelected ? ITEM_SELECTOR : ITEM_SELECTION_GAP}
</Text>
)}
itemComponent={({ isSelected, label }) => (
<Text color={isSelected ? "cyan" : "white"}>{label}</Text>
)}
/>

<Box marginTop={1}>
<Text dimColor italic>
Use ↑↓ arrows and Enter to select
</Text>
</Box>
</>
)}
</Box>
);
return null;
};

export default LocalDiffPrompt;
16 changes: 14 additions & 2 deletions src/flows/LocalReview/LocalPullRequestReview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ const LOCAL_WALKTHROUGH_PROMPT =
interface LocalPullRequestReviewProps {
diffText: string;
repoName: string;
currentBranch: string | null;
defaultBranch: string | null;
onComplete: () => void;
onBack: () => void;
}
Expand All @@ -32,6 +34,8 @@ const LOCAL_PR_NUMBER = 0;
const LocalPullRequestReview: React.FC<LocalPullRequestReviewProps> = ({
diffText,
repoName,
currentBranch,
defaultBranch,
onComplete,
onBack,
}) => {
Expand Down Expand Up @@ -118,8 +122,16 @@ const LocalPullRequestReview: React.FC<LocalPullRequestReviewProps> = ({
prId={LOCAL_PR_ID}
fullRepoName={repoName}
prNumber={LOCAL_PR_NUMBER}
chatTitle="Local Changes Walkthrough"
chatDescription="Walkthrough of local changes. Press ESC to go back."
chatTitle={
currentBranch
? `${currentBranch} vs ${defaultBranch}`
: "Uncommitted Changes Walkthrough"
}
chatDescription={
currentBranch
? `Walkthrough of changes on ${currentBranch} vs ${defaultBranch}. Press ESC to go back.`
: "Walkthrough of uncommitted changes. Press ESC to go back."
}
chatInput={LOCAL_WALKTHROUGH_PROMPT}
outputInitialMessage={false}
buildChatRequestOverride={buildLocalChatRequest}
Expand Down
23 changes: 16 additions & 7 deletions src/flows/Review/Review.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,9 @@ import IntegrationsCheck from "../Integration/IntegrationsCheck.js";
import PostReviewPrompt, { PostReviewAction } from "./PostReviewPrompt.js";
import { logger } from "../../lib/logger.js";
import PullRequestReview from "../../components/PullRequestReview.js";
import LocalDiffPrompt from "../LocalReview/LocalDiffPrompt.js";
import LocalDiffPrompt, {
type DiffResult,
} from "../LocalReview/LocalDiffPrompt.js";
import LocalPullRequestReview from "../LocalReview/LocalPullRequestReview.js";
import { getRepoName } from "../../lib/git.js";
import { MAIN_COLOR } from "../../theme/colors.js";
Expand Down Expand Up @@ -54,7 +56,7 @@ type FlowState =
}
| {
step: "localReview";
diffText: string;
diffResult: DiffResult;
}
| {
step: "localReviewComplete";
Expand Down Expand Up @@ -185,8 +187,8 @@ const InternalReviewFlow: React.FC<InternalReviewFlowProps> = ({ isLocal }) => {
setFlowState({ step: "localDiffPrompt" });
};

const handleLocalDiffReady = (diffText: string) => {
setFlowState({ step: "localReview", diffText });
const handleLocalDiffReady = (result: DiffResult) => {
setFlowState({ step: "localReview", diffResult: result });
};

const handleLocalDiffError = (message: string) => {
Expand Down Expand Up @@ -266,21 +268,28 @@ const InternalReviewFlow: React.FC<InternalReviewFlowProps> = ({ isLocal }) => {
</Box>
);

case "localReview":
case "localReview": {
const { diffResult } = flowState;
const reviewLabel = diffResult.currentBranch
? `${diffResult.currentBranch} vs ${diffResult.defaultBranch}`
: "uncommitted changes";
return (
<Box flexDirection="column">
<Box marginBottom={1}>
<Text color="green">✓ Reviewing local changes </Text>
<Text color="green">✓ Reviewing {reviewLabel} </Text>
<Text color="yellow">[{getRepoName()}]</Text>
</Box>
<LocalPullRequestReview
diffText={flowState.diffText}
diffText={diffResult.diffText}
repoName={getRepoName()}
currentBranch={diffResult.currentBranch}
defaultBranch={diffResult.defaultBranch}
onComplete={handleLocalReviewComplete}
onBack={handleLocalReviewBack}
/>
</Box>
);
}

case "localReviewComplete":
return (
Expand Down
100 changes: 41 additions & 59 deletions src/lib/git.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,17 +22,51 @@ function gitExec(args: string): string {
}
}

export function getStagedDiff(): string {
return gitExec("diff --cached");
export function getDiff(): string {
return gitExec("diff HEAD");
}

export function getUnstagedFiles(): string[] {
const output = gitExec("diff --name-only");
return output ? output.split("\n") : [];
export function getCurrentBranch(): string {
return gitExec("rev-parse --abbrev-ref HEAD");
}

export function getAllDiff(): string {
return gitExec("diff HEAD");
export function getDefaultBranch(): string | null {
try {
const ref = gitExec("symbolic-ref refs/remotes/origin/HEAD");
return ref.split("/").pop() ?? null;
} catch {
// origin/HEAD not set — check for common defaults
for (const branch of ["main", "master"]) {
try {
gitExec(`rev-parse --verify refs/remotes/origin/${branch}`);
return branch;
} catch {
console.trace(`Branch ${branch} not found`);
}
}
return null;
}
}

export function getBranchDiff(baseBranch: string): string {
return gitExec(`diff origin/${baseBranch}...HEAD`);
}

export function isOnNonDefaultBranch(): {
onFeatureBranch: boolean;
currentBranch: string;
defaultBranch: string | null;
} {
const currentBranch = getCurrentBranch();
if (currentBranch === "HEAD") {
return { onFeatureBranch: false, currentBranch, defaultBranch: null };
}
const defaultBranch = getDefaultBranch();
return {
onFeatureBranch: defaultBranch !== null && currentBranch !== defaultBranch,
currentBranch,
defaultBranch,
};
}

export function getRepoName(): string {
Expand All @@ -52,55 +86,3 @@ export function getRepoName(): string {
return path.basename(process.cwd());
}
}

export function hasStagedChanges(): boolean {
return getStagedDiff().length > 0;
}

export interface ChangeSummary {
modified: number;
added: number;
deleted: number;
}

export function getChangeSummary(): ChangeSummary | null {
const staged = gitExec("diff --cached --name-status");
const unstaged = gitExec("diff --name-status");

// Combine and deduplicate by filename (staged takes precedence)
const fileStatuses = new Map<string, string>();
for (const line of [...unstaged.split("\n"), ...staged.split("\n")]) {
if (!line.trim()) continue;
const [status, ...fileParts] = line.split("\t");
const file = fileParts.join("\t");
if (status && file) {
fileStatuses.set(file, status.charAt(0));
}
}

if (fileStatuses.size === 0) return null;

let modified = 0;
let added = 0;
let deleted = 0;

for (const status of fileStatuses.values()) {
switch (status) {
case "A":
added++;
break;
case "D":
deleted++;
break;
default:
modified++;
break;
}
}

return { modified, added, deleted };
}

export function hasAnyChanges(): boolean {
return getChangeSummary() !== null;
}
Loading