Files
kandev/apps/web/hooks/domains/session/use-session-git.ts
T

190 lines
6.2 KiB
TypeScript

"use client";
import { useState, useCallback, useEffect, useMemo } from "react";
import { useSessionGitStatus } from "./use-session-git-status";
import { useSessionCommits } from "./use-session-commits";
import { useCumulativeDiff } from "./use-cumulative-diff";
import { useGitOperations } from "@/hooks/use-git-operations";
import type {
FileInfo,
SessionCommit,
CumulativeDiff,
} from "@/lib/state/slices/session-runtime/types";
import type { GitOperationResult, PRCreateResult } from "@/hooks/use-git-operations";
export type { GitOperationResult, PRCreateResult };
export type SessionGit = {
// Branch info
branch: string | null;
remoteBranch: string | null;
ahead: number;
behind: number;
// Files (raw FileInfo from store)
allFiles: FileInfo[];
unstagedFiles: FileInfo[];
stagedFiles: FileInfo[];
// Commits
commits: SessionCommit[];
cumulativeDiff: CumulativeDiff | null;
commitsLoading: boolean;
// Derived state — single source of truth for all git-dependent UI
hasUnstaged: boolean;
hasStaged: boolean;
hasCommits: boolean;
hasChanges: boolean; // hasUnstaged || hasStaged
hasAnything: boolean; // hasChanges || hasCommits
canStageAll: boolean; // hasUnstaged
canCommit: boolean; // hasStaged
canPush: boolean; // ahead > 0
canCreatePR: boolean; // hasCommits
// Operation state
isLoading: boolean;
loadingOperation: string | null;
pendingStageFiles: Set<string>;
// Actions
pull: (rebase?: boolean) => Promise<GitOperationResult>;
push: (options?: { force?: boolean; setUpstream?: boolean }) => Promise<GitOperationResult>;
rebase: (baseBranch: string) => Promise<GitOperationResult>;
merge: (baseBranch: string) => Promise<GitOperationResult>;
abort: (operation: "merge" | "rebase") => Promise<GitOperationResult>;
commit: (message: string, stageAll?: boolean, amend?: boolean) => Promise<GitOperationResult>;
stage: (paths?: string[]) => Promise<GitOperationResult>;
stageFile: (paths: string[]) => Promise<GitOperationResult>;
stageAll: () => Promise<GitOperationResult>;
unstage: (paths?: string[]) => Promise<GitOperationResult>;
unstageFile: (paths: string[]) => Promise<GitOperationResult>;
unstageAll: () => Promise<GitOperationResult>;
discard: (paths?: string[]) => Promise<GitOperationResult>;
revertCommit: (commitSHA: string) => Promise<GitOperationResult>;
renameBranch: (newName: string) => Promise<GitOperationResult>;
reset: (commitSHA: string, mode: "soft" | "hard") => Promise<GitOperationResult>;
createPR: (
title: string,
body: string,
baseBranch?: string,
draft?: boolean,
) => Promise<PRCreateResult>;
};
export function useSessionGit(sessionId: string | null | undefined): SessionGit {
const sid = sessionId ?? null;
const gitStatus = useSessionGitStatus(sid);
const { commits, loading: commitsLoading } = useSessionCommits(sid);
const { diff: cumulativeDiff } = useCumulativeDiff(sid);
const gitOps = useGitOperations(sid);
const [pendingStageFiles, setPendingStageFiles] = useState<Set<string>>(new Set());
const allFiles = useMemo<FileInfo[]>(
() => (gitStatus?.files ? Object.values(gitStatus.files) : []),
[gitStatus],
);
const unstagedFiles = useMemo(() => allFiles.filter((f) => !f.staged), [allFiles]);
const stagedFiles = useMemo(() => allFiles.filter((f) => f.staged), [allFiles]);
// Clear pending indicators when git status updates (files changed)
useEffect(() => {
if (pendingStageFiles.size > 0) setPendingStageFiles(new Set());
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [allFiles]);
const ahead = gitStatus?.ahead ?? 0;
const hasUnstaged = unstagedFiles.length > 0;
const hasStaged = stagedFiles.length > 0;
const hasCommits = commits.length > 0;
// eslint-disable-next-line react-hooks/exhaustive-deps -- depend on stable fn ref, not the whole gitOps object
const stageAll = useCallback(async () => gitOps.stage(), [gitOps.stage]);
// eslint-disable-next-line react-hooks/exhaustive-deps -- depend on stable fn ref, not the whole gitOps object
const unstageAll = useCallback(async () => gitOps.unstage(), [gitOps.unstage]);
const stageFile = useCallback(
async (paths: string[]) => {
for (const p of paths) setPendingStageFiles((prev) => new Set(prev).add(p));
try {
return await gitOps.stage(paths);
} catch (err) {
setPendingStageFiles((prev) => {
const next = new Set(prev);
for (const p of paths) next.delete(p);
return next;
});
throw err;
}
},
// eslint-disable-next-line react-hooks/exhaustive-deps -- depend on stable fn ref, not the whole gitOps object
[gitOps.stage],
);
const unstageFile = useCallback(
async (paths: string[]) => {
for (const p of paths) setPendingStageFiles((prev) => new Set(prev).add(p));
try {
return await gitOps.unstage(paths);
} catch (err) {
setPendingStageFiles((prev) => {
const next = new Set(prev);
for (const p of paths) next.delete(p);
return next;
});
throw err;
}
},
// eslint-disable-next-line react-hooks/exhaustive-deps -- depend on stable fn ref, not the whole gitOps object
[gitOps.unstage],
);
return {
branch: gitStatus?.branch ?? null,
remoteBranch: gitStatus?.remote_branch ?? null,
ahead,
behind: gitStatus?.behind ?? 0,
allFiles,
unstagedFiles,
stagedFiles,
commits,
cumulativeDiff,
commitsLoading: commitsLoading ?? false,
hasUnstaged,
hasStaged,
hasCommits,
hasChanges: hasUnstaged || hasStaged,
hasAnything: hasUnstaged || hasStaged || hasCommits,
canStageAll: hasUnstaged,
canCommit: hasStaged,
canPush: ahead > 0,
canCreatePR: hasCommits,
isLoading: gitOps.isLoading,
loadingOperation: gitOps.loadingOperation,
pendingStageFiles,
pull: gitOps.pull,
push: gitOps.push,
rebase: gitOps.rebase,
merge: gitOps.merge,
abort: gitOps.abort,
commit: gitOps.commit,
stage: gitOps.stage,
stageFile,
stageAll,
unstage: gitOps.unstage,
unstageFile,
unstageAll,
discard: gitOps.discard,
revertCommit: gitOps.revertCommit,
renameBranch: gitOps.renameBranch,
reset: gitOps.reset,
createPR: gitOps.createPR,
};
}