feat: Filter git revisions to show only base and current branches (#47)

* feat: Unify git revisions API to current-revisions endpoint

- Add getCurrentRevisions to GitController returning base branch, current branch, head, and commits
- Implement findBaseBranch in GitService to identify base branch from commit history
- Add getCommitsBetweenBranches to get commits between base and target branch
- Remove separate branches and commits API endpoints
- Update frontend to use unified gitCurrentRevisionsQuery
- Replace useGitBranches and useGitCommits with useGitCurrentRevisions hook

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* feat: Display current branch in session header

- Move useGitCurrentRevisions hook to SessionPageMain level
- Display current branch badge with GitBranchIcon in session header
- Pass revisionsData from parent to DiffModal to avoid duplicate API calls
- Update DiffModalProps to accept optional revisionsData parameter
- Show current branch between project path and session ID badges

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

---------

Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
きむそん
2025-11-02 12:22:41 +09:00
committed by GitHub
parent 76aaf1013c
commit 158db20b52
9 changed files with 406 additions and 147 deletions

View File

@@ -1,6 +1,7 @@
import { Trans } from "@lingui/react";
import { useMutation } from "@tanstack/react-query";
import {
GitBranchIcon,
GitCompareIcon,
LoaderIcon,
MenuIcon,
@@ -17,6 +18,7 @@ import { Badge } from "../../../../../../components/ui/badge";
import { honoClient } from "../../../../../../lib/api/client";
import { useProject } from "../../../hooks/useProject";
import { firstUserMessageToTitle } from "../../../services/firstCommandToTitle";
import { useGitCurrentRevisions } from "../hooks/useGit";
import { useSession } from "../hooks/useSession";
import { useSessionProcess } from "../hooks/useSessionProcess";
import { ConversationList } from "./conversationList/ConversationList";
@@ -39,6 +41,7 @@ export const SessionPageMain: FC<{
const { currentPermissionRequest, isDialogOpen, onPermissionResponse } =
usePermissionRequests();
const [isDiffModalOpen, setIsDiffModalOpen] = useState(false);
const { data: revisionsData } = useGitCurrentRevisions(projectId);
const abortTask = useMutation({
mutationFn: async (sessionProcessId: string) => {
@@ -122,6 +125,15 @@ export const SessionPageMain: FC<{
{project.meta.projectPath ?? project.claudeProjectPath}
</Badge>
)}
{revisionsData?.success && revisionsData.data.currentBranch && (
<Badge
variant="secondary"
className="h-6 sm:h-8 text-xs sm:text-sm flex items-center gap-1"
>
<GitBranchIcon className="w-3 h-3 sm:w-3.5 sm:h-3.5" />
{revisionsData.data.currentBranch.name}
</Badge>
)}
<Badge
variant="secondary"
className="h-6 sm:h-8 text-xs sm:text-sm flex items-center"
@@ -256,6 +268,7 @@ export const SessionPageMain: FC<{
projectId={projectId}
isOpen={isDiffModalOpen}
onOpenChange={setIsDiffModalOpen}
revisionsData={revisionsData}
/>
{/* Permission Dialog */}

View File

@@ -25,8 +25,7 @@ import { cn } from "@/lib/utils";
import {
useCommitAndPush,
useCommitFiles,
useGitBranches,
useGitCommits,
useGitCurrentRevisions,
useGitDiff,
usePushCommits,
} from "../../hooks/useGit";
@@ -141,6 +140,7 @@ export const DiffModal: FC<DiffModalProps> = ({
projectId,
defaultCompareFrom = "HEAD",
defaultCompareTo = "working",
revisionsData: parentRevisionsData,
}) => {
const { i18n } = useLingui();
const commitMessageId = useId();
@@ -158,11 +158,10 @@ export const DiffModal: FC<DiffModalProps> = ({
// Commit section collapse state (default: collapsed)
const [isCommitSectionExpanded, setIsCommitSectionExpanded] = useState(false);
// API hooks
const { data: branchesData, isLoading: isLoadingBranches } =
useGitBranches(projectId);
const { data: commitsData, isLoading: isLoadingCommits } =
useGitCommits(projectId);
// API hooks - use parent data if available, otherwise fetch
const { data: fetchedRevisionsData, isLoading: isLoadingRevisions } =
useGitCurrentRevisions(projectId);
const revisionsData = parentRevisionsData ?? fetchedRevisionsData;
const {
mutate: getDiff,
data: diffData,
@@ -173,9 +172,9 @@ export const DiffModal: FC<DiffModalProps> = ({
const pushMutation = usePushCommits(projectId);
const commitAndPushMutation = useCommitAndPush(projectId);
// Transform branches and commits data to GitRef format
// Transform revisions data to GitRef format
const gitRefs: GitRef[] =
branchesData?.success && branchesData.data
revisionsData?.success && revisionsData.data
? [
{
name: "working" as const,
@@ -187,21 +186,35 @@ export const DiffModal: FC<DiffModalProps> = ({
type: "commit" as const,
displayName: "HEAD",
},
...branchesData.data.map((branch) => ({
name: `branch:${branch.name}` as const,
type: "branch" as const,
displayName: branch.name + (branch.current ? " (current)" : ""),
sha: branch.commit,
})),
// Add commits from current branch
...(commitsData?.success && commitsData.data
? commitsData.data.map((commit) => ({
name: `commit:${commit.sha}` as const,
type: "commit" as const,
displayName: `${commit.message.substring(0, 50)}${commit.message.length > 50 ? "..." : ""}`,
sha: commit.sha,
}))
// Add base branch if exists
...(revisionsData.data.baseBranch
? [
{
name: `branch:${revisionsData.data.baseBranch.name}` as const,
type: "branch" as const,
displayName: `${revisionsData.data.baseBranch.name} (base)`,
sha: revisionsData.data.baseBranch.commit,
},
]
: []),
// Add current branch if exists
...(revisionsData.data.currentBranch
? [
{
name: `branch:${revisionsData.data.currentBranch.name}` as const,
type: "branch" as const,
displayName: `${revisionsData.data.currentBranch.name} (current)`,
sha: revisionsData.data.currentBranch.commit,
},
]
: []),
// Add commits from current branch
...revisionsData.data.commits.map((commit) => ({
name: `commit:${commit.sha}` as const,
type: "commit" as const,
displayName: `${commit.message.substring(0, 50)}${commit.message.length > 50 ? "..." : ""}`,
sha: commit.sha,
})),
]
: [];
@@ -397,10 +410,7 @@ export const DiffModal: FC<DiffModalProps> = ({
<Button
onClick={handleCompare}
disabled={
isDiffLoading ||
isLoadingBranches ||
isLoadingCommits ||
compareFrom === compareTo
isDiffLoading || isLoadingRevisions || compareFrom === compareTo
}
className="sm:self-end w-full sm:w-auto"
>

View File

@@ -45,4 +45,36 @@ export interface DiffModalProps {
onOpenChange: (open: boolean) => void;
defaultCompareFrom?: string;
defaultCompareTo?: string;
revisionsData?:
| {
success: true;
data: {
baseBranch: {
name: string;
current: boolean;
remote?: string;
commit: string;
ahead?: number;
behind?: number;
} | null;
currentBranch: {
name: string;
current: boolean;
remote?: string;
commit: string;
ahead?: number;
behind?: number;
} | null;
head: string | null;
commits: Array<{
sha: string;
message: string;
author: string;
date: string;
}>;
};
}
| {
success: false;
};
}

View File

@@ -1,22 +1,11 @@
import { useMutation, useQuery } from "@tanstack/react-query";
import { honoClient } from "@/lib/api/client";
import {
gitBranchesQuery,
gitCommitsQuery,
} from "../../../../../../lib/api/queries";
import { gitCurrentRevisionsQuery } from "../../../../../../lib/api/queries";
export const useGitBranches = (projectId: string) => {
export const useGitCurrentRevisions = (projectId: string) => {
return useQuery({
queryKey: gitBranchesQuery(projectId).queryKey,
queryFn: gitBranchesQuery(projectId).queryFn,
staleTime: 30000, // 30 seconds
});
};
export const useGitCommits = (projectId: string) => {
return useQuery({
queryKey: gitCommitsQuery(projectId).queryKey,
queryFn: gitCommitsQuery(projectId).queryFn,
queryKey: gitCurrentRevisionsQuery(projectId).queryKey,
queryFn: gitCurrentRevisionsQuery(projectId).queryFn,
staleTime: 30000, // 30 seconds
});
};

View File

@@ -132,36 +132,20 @@ export const sessionProcessesQuery = {
},
} as const;
export const gitBranchesQuery = (projectId: string) =>
export const gitCurrentRevisionsQuery = (projectId: string) =>
({
queryKey: ["git", "branches", projectId],
queryKey: ["git", "current-revisions", projectId],
queryFn: async () => {
const response = await honoClient.api.projects[
":projectId"
].git.branches.$get({
const response = await honoClient.api.projects[":projectId"].git[
"current-revisions"
].$get({
param: { projectId },
});
if (!response.ok) {
throw new Error(`Failed to fetch branches: ${response.statusText}`);
}
return await response.json();
},
}) as const;
export const gitCommitsQuery = (projectId: string) =>
({
queryKey: ["git", "commits", projectId],
queryFn: async () => {
const response = await honoClient.api.projects[
":projectId"
].git.commits.$get({
param: { projectId },
});
if (!response.ok) {
throw new Error(`Failed to fetch commits: ${response.statusText}`);
throw new Error(
`Failed to fetch current revisions: ${response.statusText}`,
);
}
return await response.json();

View File

@@ -10,71 +10,6 @@ const LayerImpl = Effect.gen(function* () {
const gitService = yield* GitService;
const projectRepository = yield* ProjectRepository;
const getGitBranches = (options: { projectId: string }) =>
Effect.gen(function* () {
const { projectId } = options;
const { project } = yield* projectRepository.getProject(projectId);
if (project.meta.projectPath === null) {
return {
response: { error: "Project path not found" },
status: 400,
} as const satisfies ControllerResponse;
}
const projectPath = project.meta.projectPath;
const branches = yield* Effect.either(
gitService.getBranches(projectPath),
);
if (Either.isLeft(branches)) {
return {
response: {
success: false,
},
status: 200,
} as const satisfies ControllerResponse;
}
return {
response: branches.right,
status: 200,
} as const satisfies ControllerResponse;
});
const getGitCommits = (options: { projectId: string }) =>
Effect.gen(function* () {
const { projectId } = options;
const { project } = yield* projectRepository.getProject(projectId);
if (project.meta.projectPath === null) {
return {
response: { error: "Project path not found" },
status: 400,
} as const satisfies ControllerResponse;
}
const projectPath = project.meta.projectPath;
const commits = yield* Effect.either(gitService.getCommits(projectPath));
if (Either.isLeft(commits)) {
return {
response: {
success: false,
},
status: 200,
} as const satisfies ControllerResponse;
}
return {
response: commits.right,
status: 200,
} as const satisfies ControllerResponse;
});
const getGitDiff = (options: {
projectId: string;
fromRef: string;
@@ -334,13 +269,115 @@ const LayerImpl = Effect.gen(function* () {
} as const satisfies ControllerResponse;
});
const getCurrentRevisions = (options: { projectId: string }) =>
Effect.gen(function* () {
const { projectId } = options;
const { project } = yield* projectRepository.getProject(projectId);
if (project.meta.projectPath === null) {
return {
response: { error: "Project path not found" },
status: 400,
} as const satisfies ControllerResponse;
}
const projectPath = project.meta.projectPath;
// Get current branch
const currentBranchResult = yield* Effect.either(
gitService.getCurrentBranch(projectPath),
);
if (Either.isLeft(currentBranchResult)) {
return {
response: {
success: false,
},
status: 200,
} as const satisfies ControllerResponse;
}
const currentBranch = currentBranchResult.right;
// Find base branch
const baseBranchResult = yield* Effect.either(
gitService.findBaseBranch(projectPath, currentBranch),
);
// Get all branches to extract branch details
const allBranchesResult = yield* Effect.either(
gitService.getBranches(projectPath),
);
if (Either.isLeft(allBranchesResult)) {
return {
response: {
success: false,
},
status: 200,
} as const satisfies ControllerResponse;
}
const allBranches = allBranchesResult.right.data;
// Find current branch details
const currentBranchDetails = allBranches.find(
(branch) => branch.name === currentBranch,
);
// Find base branch details if exists
let baseBranchDetails: (typeof allBranches)[number] | undefined;
if (Either.isRight(baseBranchResult) && baseBranchResult.right !== null) {
const baseBranchName = baseBranchResult.right.branch;
baseBranchDetails = allBranches.find(
(branch) => branch.name === baseBranchName,
);
}
// Get commits if base branch exists
let commits: Array<{
sha: string;
message: string;
author: string;
date: string;
}> = [];
if (Either.isRight(baseBranchResult) && baseBranchResult.right !== null) {
const baseBranchHash = baseBranchResult.right.hash;
const commitsResult = yield* Effect.either(
gitService.getCommitsBetweenBranches(
projectPath,
baseBranchHash,
"HEAD",
),
);
if (Either.isRight(commitsResult)) {
commits = commitsResult.right.data;
}
}
return {
response: {
success: true,
data: {
baseBranch: baseBranchDetails ?? null,
currentBranch: currentBranchDetails ?? null,
head: currentBranchDetails?.commit ?? null,
commits,
},
},
status: 200,
} as const satisfies ControllerResponse;
});
return {
getGitBranches,
getGitCommits,
getGitDiff,
commitFiles,
pushCommits,
commitAndPush,
getCurrentRevisions,
};
});

View File

@@ -69,3 +69,41 @@ describe("GitService.push", () => {
expect(true).toBe(true);
});
});
describe("GitService.findBaseBranch", () => {
test("should return null when no base branch is found", async () => {
const gitService = await Effect.runPromise(
GitService.pipe(Effect.provide(testLayer)),
);
const result = await Effect.runPromise(
Effect.either(
gitService.findBaseBranch("/tmp/nonexistent", "feature-branch"),
).pipe(Effect.provide(NodeContext.layer)),
);
// Should fail due to missing repo
expect(Either.isLeft(result)).toBe(true);
});
});
describe("GitService.getCommitsBetweenBranches", () => {
test("should fail with missing repo", async () => {
const gitService = await Effect.runPromise(
GitService.pipe(Effect.provide(testLayer)),
);
const result = await Effect.runPromise(
Effect.either(
gitService.getCommitsBetweenBranches(
"/tmp/nonexistent",
"base-branch",
"HEAD",
),
).pipe(Effect.provide(NodeContext.layer)),
);
// Should fail due to missing repo
expect(Either.isLeft(result)).toBe(true);
});
});

View File

@@ -226,6 +226,168 @@ const LayerImpl = Effect.gen(function* () {
return { branch, output: "success" };
});
const getBranchHash = (cwd: string, branchName: string) =>
Effect.gen(function* () {
const result = yield* execGitCommand(["rev-parse", branchName], cwd).pipe(
Effect.map((output) => output.trim().split("\n")[0] ?? null),
);
return result;
});
const getBranchNamesByCommitHash = (cwd: string, hash: string) =>
Effect.gen(function* () {
const result = yield* execGitCommand(
["branch", "--contains", hash, "--format=%(refname:short)"],
cwd,
);
return result
.split("\n")
.map((line) => line.trim())
.filter((line) => line !== "");
});
const compareCommitHash = (
cwd: string,
targetHash: string,
compareHash: string,
) =>
Effect.gen(function* () {
const aheadResult = yield* execGitCommand(
["rev-list", `${targetHash}..${compareHash}`],
cwd,
);
const aheadCounts = aheadResult
.split("\n")
.map((line) => line.trim())
.filter((line) => line !== "").length;
const behindResult = yield* execGitCommand(
["rev-list", `${compareHash}..${targetHash}`],
cwd,
);
const behindCounts = behindResult
.split("\n")
.map((line) => line.trim())
.filter((line) => line !== "").length;
if (aheadCounts === 0 && behindCounts === 0) {
return "un-related" as const;
}
if (aheadCounts > 0) {
return "ahead" as const;
}
if (behindCounts > 0) {
return "behind" as const;
}
return "un-related" as const;
});
const getCommitsWithParent = (
cwd: string,
options: { offset: number; limit: number },
) =>
Effect.gen(function* () {
const { offset, limit } = options;
const result = yield* execGitCommand(
[
"log",
"-n",
String(limit),
"--skip",
String(offset),
"--graph",
"--pretty=format:%h %p",
],
cwd,
);
const lines = result
.split("\n")
.map((line) => line.trim())
.filter((line) => line !== "");
const commits: Array<{ current: string; parent: string }> = [];
for (const line of lines) {
const match = /^\* (?<current>.+) (?<parent>.+)$/.exec(line);
if (match?.groups?.current && match.groups.parent) {
commits.push({
current: match.groups.current,
parent: match.groups.parent,
});
}
}
return commits;
});
const findBaseBranch = (cwd: string, targetBranch: string) =>
Effect.gen(function* () {
let offset = 0;
const limit = 20;
while (offset < 100) {
const commits = yield* getCommitsWithParent(cwd, { offset, limit });
for (const commit of commits) {
const branchNames = yield* getBranchNamesByCommitHash(
cwd,
commit.current,
);
if (!branchNames.includes(targetBranch)) {
continue;
}
const otherBranchNames = branchNames.filter(
(branchName) => branchName !== targetBranch,
);
if (otherBranchNames.length === 0) {
continue;
}
for (const branchName of otherBranchNames) {
const comparison = yield* compareCommitHash(
cwd,
targetBranch,
branchName,
);
if (comparison === "behind") {
return { branch: branchName, hash: commit.current };
}
}
}
offset += limit;
}
return null;
});
const getCommitsBetweenBranches = (
cwd: string,
baseBranch: string,
targetBranch: string,
) =>
Effect.gen(function* () {
const result = yield* execGitCommand(
[
"log",
`${baseBranch}..${targetBranch}`,
"--format=%H|%s|%an|%ad",
"--date=iso",
],
cwd,
);
return parseGitCommitsOutput(result);
});
return {
getBranches,
getCurrentBranch,
@@ -234,6 +396,12 @@ const LayerImpl = Effect.gen(function* () {
stageFiles,
commit,
push,
getBranchHash,
getBranchNamesByCommitHash,
compareCommitHash,
getCommitsWithParent,
findBaseBranch,
getCommitsBetweenBranches,
};
});

View File

@@ -196,23 +196,11 @@ export const routes = (app: HonoAppType) =>
* GitController Routes
*/
.get("/api/projects/:projectId/git/branches", async (c) => {
.get("/api/projects/:projectId/git/current-revisions", async (c) => {
const response = await effectToResponse(
c,
gitController
.getGitBranches({
...c.req.param(),
})
.pipe(Effect.provide(runtime)),
);
return response;
})
.get("/api/projects/:projectId/git/commits", async (c) => {
const response = await effectToResponse(
c,
gitController
.getGitCommits({
.getCurrentRevisions({
...c.req.param(),
})
.pipe(Effect.provide(runtime)),