Files
kandev/apps/web/hooks/domains/session/use-session-git.ts
T
2026-06-02 21:48:23 +01:00

742 lines
28 KiB
TypeScript

"use client";
import { useState, useCallback, useEffect, useMemo, useRef } from "react";
import { useSessionGitStatus, useSessionGitStatusByRepo } 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 { createDebugLogger, isDebug } from "@/lib/debug/log";
import type {
FileInfo,
SessionCommit,
CumulativeDiff,
} from "@/lib/state/slices/session-runtime/types";
import type {
GitOperationResult as RawGitOperationResult,
PRCreateResult,
} from "@/hooks/use-git-operations";
/**
* Per-repo result emitted by frontend-side fan-outs (commit, push, pull,
* rebase, merge, abort, stage-all, unstage-all). Each entry mirrors the wire
* shape returned by agentctl for one repo so the toast layer can describe
* partial successes ("succeeded in [A], failed in [B]: …").
*/
export type PerRepoOperationResult = {
repository_name: string;
success: boolean;
output: string;
error?: string;
};
/**
* Aggregated result of a multi-repo fan-out. The `success` / `output` /
* `error` fields preserve the legacy `GitOperationResult` shape so existing
* single-repo callers keep working unchanged. The optional `per_repo` field
* is frontend-internal — it never goes back to the backend wire and only
* appears when the op was fanned out across more than one repo.
*/
export type GitOperationResult = RawGitOperationResult & {
per_repo?: PerRepoOperationResult[];
};
export type { PRCreateResult };
const debugDeriv = createDebugLogger("git-status:derive");
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. The optional `repo` param scopes the op to a single repo
// subpath in multi-repo workspaces; omit for single-repo.
// When `repo` is omitted in multi-repo mode, these fan out across every repo
// (push gates on ahead > 0; pull/rebase/merge/abort hit every repo).
pull: (rebase?: boolean, repo?: string) => Promise<GitOperationResult>;
push: (
options?: { force?: boolean; setUpstream?: boolean },
repo?: string,
) => Promise<GitOperationResult>;
rebase: (baseBranch: string, repo?: string) => Promise<GitOperationResult>;
merge: (baseBranch: string, repo?: string) => Promise<GitOperationResult>;
abort: (operation: "merge" | "rebase", repo?: string) => Promise<GitOperationResult>;
/** Distinct repo names present in the session's files; empty for single-repo. */
repoNames: string[];
/** Per-repo branch / ahead / behind / hasStaged for header buttons. */
perRepoStatus: Array<{
repository_name: string;
branch: string | null;
ahead: number;
behind: number;
hasStaged: boolean;
hasUnstaged: boolean;
}>;
// Multi-repo: when `repo` is omitted, commit fans out one call per repo with
// staged changes so each repo gets its own commit. With `repo`, only that
// repo is committed.
commit: (
message: string,
stageAll?: boolean,
amend?: boolean,
repo?: string,
) => Promise<GitOperationResult>;
// Multi-repo: when `repo` is provided, the op runs against that repo only —
// use this for per-file actions to avoid path-based lookup collisions
// (same-named files like README.md exist in multiple repos). When `repo`
// is omitted, the op falls back to a path-based lookup against allFiles
// (works for unique paths) or fans out across every repo present.
stage: (paths?: string[], repo?: string) => Promise<GitOperationResult>;
stageFile: (paths: string[], repo?: string) => Promise<GitOperationResult>;
stageAll: () => Promise<GitOperationResult>;
unstage: (paths?: string[], repo?: string) => Promise<GitOperationResult>;
unstageFile: (paths: string[], repo?: string) => Promise<GitOperationResult>;
unstageAll: () => Promise<GitOperationResult>;
discard: (paths?: string[], repo?: string) => Promise<GitOperationResult>;
revertCommit: (commitSHA: string, repo?: string) => Promise<GitOperationResult>;
renameBranch: (newName: string) => Promise<GitOperationResult>;
reset: (commitSHA: string, mode: "soft" | "hard", repo?: string) => Promise<GitOperationResult>;
createPR: (
title: string,
body: string,
baseBranch?: string,
draft?: boolean,
repo?: string,
) => Promise<PRCreateResult>;
};
/**
* Groups paths into per-repo buckets using a path → repository_name lookup.
* Paths missing a known repo land under "" (the single-repo bucket) so legacy
* single-repo workspaces and stray entries stay correct. Insertion order in
* `paths` is preserved within each bucket.
*
* Exported for testing — also used internally by useSessionGit's stage/unstage
* fan-out, where every per-repo bucket becomes one agentctl call.
*/
export function groupPathsByRepoName(
paths: string[],
repoForPath: Map<string, string>,
): Map<string, string[]> {
const buckets = new Map<string, string[]>();
for (const p of paths) {
const repo = repoForPath.get(p) ?? "";
const list = buckets.get(repo);
if (list) list.push(p);
else buckets.set(repo, [p]);
}
return buckets;
}
/**
* Builds the SessionGit's flat file list. For multi-repo workspaces it
* stamps each FileInfo with its repository_name so consumers can group;
* for single-repo it returns the legacy single-status files unchanged.
*/
function aggregateFilesAcrossRepos(
statusByRepo: ReturnType<typeof useSessionGitStatusByRepo>,
gitStatus: ReturnType<typeof useSessionGitStatus>,
): FileInfo[] {
if (statusByRepo.length > 0) {
const out: FileInfo[] = [];
for (const { repository_name, status } of statusByRepo) {
if (!status?.files) continue;
for (const f of Object.values(status.files)) {
out.push(repository_name ? { ...f, repository_name } : f);
}
}
return out;
}
return gitStatus?.files ? Object.values(gitStatus.files) : [];
}
type StageDispatchArgs = {
gitOps: ReturnType<typeof useGitOperations>;
repoForPath: Map<string, string>;
reposInFiles: string[];
stagedFiles: FileInfo[];
setPendingStageFiles: React.Dispatch<React.SetStateAction<Set<string>>>;
};
/**
* Encodes a (repo, path) pair as a single Set entry. We need a per-repo key
* because two repos can have files at the same relative path (README.md,
* .gitignore, etc.) and a flat path-only Set would conflate them. The
* separator "::" keeps the key trivially parseable for debugging — repo
* names don't contain "::".
*/
export function pendingKey(repo: string | undefined, path: string): string {
return `${repo ?? ""}::${path}`;
}
/**
* Aggregates a list of per-repo results into a single GitOperationResult.
* - `success` is true only if every repo succeeded
* - `output` is the joined output across repos (one line each, prefixed with the repo name)
* - `error` is the first failure's error
* - `per_repo` carries the full per-repo breakdown for the toast layer
*
* Single-repo (one entry) returns the raw result with no `per_repo` field so
* the toast handler renders the legacy single-line message.
*/
function aggregatePerRepoResults(
perRepo: PerRepoOperationResult[],
operation: string,
): GitOperationResult {
if (perRepo.length === 0) {
return { success: true, operation, output: "" };
}
if (perRepo.length === 1) {
const only = perRepo[0];
return {
success: only.success,
operation,
output: only.output,
error: only.error,
};
}
const allSucceeded = perRepo.every((r) => r.success);
const firstFailure = perRepo.find((r) => !r.success);
const joined = perRepo
.map((r) => `[${r.repository_name || "default"}] ${r.output}`.trim())
.filter(Boolean)
.join("\n");
return {
success: allSucceeded,
operation,
output: joined,
error: firstFailure?.error,
per_repo: perRepo,
};
}
/**
* Runs `op` against each repo in `repos`, collecting per-repo results.
* Continues past failures so partial-success state is surfaced (instead of
* stopping at the first error and leaving the user blind to repo A having
* already mutated).
*/
async function fanOutAcrossRepos(
repos: string[],
operation: string,
op: (repo: string) => Promise<RawGitOperationResult>,
): Promise<GitOperationResult> {
const perRepo: PerRepoOperationResult[] = [];
for (const repo of repos) {
try {
const r = await op(repo);
perRepo.push({
repository_name: repo,
success: r.success,
output: r.output,
error: r.error,
});
} catch (e) {
perRepo.push({
repository_name: repo,
success: false,
output: "",
error: e instanceof Error ? e.message : String(e),
});
}
}
return aggregatePerRepoResults(perRepo, operation);
}
/**
* Multi-repo dispatch for stage/unstage/commit/discard. The fan-out logic
* (stage-all across every repo, commit per-repo with staged changes, etc.)
* lives here so the parent hook stays small.
*/
// eslint-disable-next-line max-lines-per-function -- dispatch table, splitting further hurts readability
function useStageDispatch({
gitOps,
repoForPath,
reposInFiles,
stagedFiles,
setPendingStageFiles,
}: StageDispatchArgs) {
const groupPathsByRepo = useCallback(
(paths: string[]): Map<string, string[]> => groupPathsByRepoName(paths, repoForPath),
[repoForPath],
);
// stage-all / unstage-all fan out across every repo with files. Per-repo
// failures are collected and surfaced via `per_repo` instead of overwriting
// each iteration (Bug 3) — the toast layer renders partial-success cleanly.
const stageAll = useCallback(
async (): Promise<GitOperationResult> => {
if (reposInFiles.length <= 1) return gitOps.stage(undefined, reposInFiles[0]);
return fanOutAcrossRepos(reposInFiles, "stage", (r) => gitOps.stage(undefined, r));
},
// eslint-disable-next-line react-hooks/exhaustive-deps -- stable fn ref
[reposInFiles, gitOps.stage],
);
const unstageAll = useCallback(
async (): Promise<GitOperationResult> => {
if (reposInFiles.length <= 1) return gitOps.unstage(undefined, reposInFiles[0]);
return fanOutAcrossRepos(reposInFiles, "unstage", (r) => gitOps.unstage(undefined, r));
},
// eslint-disable-next-line react-hooks/exhaustive-deps -- stable fn ref
[reposInFiles, gitOps.unstage],
);
// commit fan-out: when no `repo` is given and multiple repos have staged
// changes, commit each one. Bug 2: previously we stopped at first failure
// and returned only that result, so a partial success ("repo A committed,
// repo B failed") looked like a total failure. Now we continue and
// aggregate, preserving each per-repo result for the toast layer.
const commit = useCallback(
async (
message: string,
stageAllOpt: boolean = true,
amend: boolean = false,
repo?: string,
): Promise<GitOperationResult> => {
if (repo !== undefined) return gitOps.commit(message, stageAllOpt, amend, repo || undefined);
const reposWithStaged = Array.from(
new Set(stagedFiles.map((f) => f.repository_name).filter((n): n is string => Boolean(n))),
);
if (reposWithStaged.length === 0) return gitOps.commit(message, stageAllOpt, amend);
if (reposWithStaged.length === 1) {
return gitOps.commit(message, stageAllOpt, amend, reposWithStaged[0]);
}
return fanOutAcrossRepos(reposWithStaged, "commit", (r) =>
gitOps.commit(message, stageAllOpt, amend, r),
);
},
// eslint-disable-next-line react-hooks/exhaustive-deps -- stable fn ref
[gitOps.commit, stagedFiles],
);
const runPerRepo = useCallback(
async (
paths: string[],
explicitRepo: string | undefined,
op: (paths: string[], repo: string | undefined) => Promise<GitOperationResult>,
): Promise<GitOperationResult> => {
if (explicitRepo !== undefined) return op(paths, explicitRepo || undefined);
const buckets = groupPathsByRepo(paths);
let last: GitOperationResult | undefined;
for (const [repo, repoPaths] of buckets) last = await op(repoPaths, repo || undefined);
return last as GitOperationResult;
},
[groupPathsByRepo],
);
const wrapPending = useCallback(
async (
paths: string[],
repo: string | undefined,
op: (rp: string[], r: string | undefined) => Promise<GitOperationResult>,
) => {
// Bug 6: key pending entries by `repo::path` so an in-flight stage in
// repo B isn't cleared when repo A's status update lands. The consumer
// `FileRow` checks membership via the same encoding.
const buckets = repo !== undefined ? new Map([[repo, paths]]) : groupPathsByRepo(paths);
const keys: string[] = [];
for (const [r, rp] of buckets) for (const p of rp) keys.push(pendingKey(r, p));
setPendingStageFiles((prev) => {
const next = new Set(prev);
for (const k of keys) next.add(k);
return next;
});
try {
return await runPerRepo(paths, repo, op);
} catch (err) {
setPendingStageFiles((prev) => {
const next = new Set(prev);
for (const k of keys) next.delete(k);
return next;
});
throw err;
}
},
[runPerRepo, setPendingStageFiles, groupPathsByRepo],
);
const stageFile = useCallback(
async (paths: string[], repo?: string) =>
wrapPending(paths, repo, (rp, r) => gitOps.stage(rp, r)),
// eslint-disable-next-line react-hooks/exhaustive-deps -- stable fn ref
[gitOps.stage, wrapPending],
);
const unstageFile = useCallback(
async (paths: string[], repo?: string) =>
wrapPending(paths, repo, (rp, r) => gitOps.unstage(rp, r)),
// eslint-disable-next-line react-hooks/exhaustive-deps -- stable fn ref
[gitOps.unstage, wrapPending],
);
const discard = useCallback(
async (paths?: string[], repo?: string) => {
if (!paths || paths.length === 0) return gitOps.discard(paths, repo);
return runPerRepo(paths, repo, (rp, r) => gitOps.discard(rp, r));
},
// eslint-disable-next-line react-hooks/exhaustive-deps -- stable fn ref
[gitOps.discard, runPerRepo],
);
return { stageAll, unstageAll, commit, stageFile, unstageFile, discard };
}
/**
* Multi-repo summary for the per-repo Pull/Push/Commit controls. Returns the
* full list of repo names known to the session (even ones with no file
* changes) plus a per-repo `branch / ahead / behind / hasStaged / hasUnstaged`
* row for each. Empty for single-repo workspaces.
*/
function useMultiRepoSummary(
statusByRepo: ReturnType<typeof useSessionGitStatusByRepo>,
allFiles: FileInfo[],
reposInFiles: string[],
) {
// Include every repo known to the session, including the empty-name entry
// for single-repo. Components render the same per-repo group structure in
// both modes — the empty name routes ops to the workspace root, named
// entries route to their respective subdirectories.
//
// Defensive filter: when there are named entries (multi-repo), drop the
// empty entry. The bare task-root tracker is supposed to stay quiet for
// multi-repo (see workspace_tracker.go's gitIndexPath guard) but a stale
// entry can linger from older builds or a bugged code path; without this
// filter the dropdown would show an extra "Repository" / primary-name row
// alongside the real per-repo entries.
const repoNamesForControls = useMemo(() => {
const seen = new Set<string>();
for (const { repository_name } of statusByRepo) seen.add(repository_name);
for (const r of reposInFiles) seen.add(r);
const all = Array.from(seen).sort((a, b) => a.localeCompare(b));
const named = all.filter((r) => r !== "");
return named.length > 0 ? named : all;
}, [statusByRepo, reposInFiles]);
const perRepoStatus = useMemo(() => {
if (statusByRepo.length === 0) return [];
const stagedByRepo = new Map<string, boolean>();
const unstagedByRepo = new Map<string, boolean>();
for (const f of allFiles) {
const r = f.repository_name ?? "";
if (f.staged) stagedByRepo.set(r, true);
else unstagedByRepo.set(r, true);
}
const hasNamed = statusByRepo.some((s) => s.repository_name !== "");
const filtered = hasNamed ? statusByRepo.filter((s) => s.repository_name !== "") : statusByRepo;
return filtered.map(({ repository_name, status }) => ({
repository_name,
branch: status?.branch ?? null,
ahead: status?.ahead ?? 0,
behind: status?.behind ?? 0,
hasStaged: stagedByRepo.get(repository_name) ?? false,
hasUnstaged: unstagedByRepo.get(repository_name) ?? false,
}));
}, [statusByRepo, allFiles]);
return { repoNamesForControls, perRepoStatus };
}
/**
* Bug 6: clears pending stage markers per-repo, not globally. Each repo's
* status streams in independently as agentctl finishes per-repo ops; if we
* cleared the whole pending Set on any allFiles change, an in-flight stage
* in repo B would lose its spinner the moment repo A finished. Detects which
* repos' status entry changed since the last render and only drops the
* corresponding `${repo}::${path}` keys.
*/
function usePerRepoPendingClear(
statusByRepo: ReturnType<typeof useSessionGitStatusByRepo>,
allFiles: FileInfo[],
setPendingStageFiles: React.Dispatch<React.SetStateAction<Set<string>>>,
) {
const prevStatusRef = useRef<Map<string, unknown>>(new Map());
useEffect(() => {
const next = new Map<string, unknown>();
const refreshed: string[] = [];
for (const { repository_name, status } of statusByRepo) {
next.set(repository_name, status);
if (prevStatusRef.current.get(repository_name) !== status) {
refreshed.push(repository_name);
}
}
// Single-repo / legacy path: gitStatus changed and there's no per-repo
// entry to track. Clear all pending — original behavior, safe because
// there's only one in-flight op at a time in single-repo.
const isLegacySingleRepo = statusByRepo.length === 0;
prevStatusRef.current = next;
if (refreshed.length === 0 && !isLegacySingleRepo) return;
setPendingStageFiles((prev) => {
if (prev.size === 0) return prev;
if (isLegacySingleRepo) return new Set();
const out = new Set<string>();
for (const key of prev) {
// key shape is `${repo}::${path}`; first "::" splits repo from path.
const sep = key.indexOf("::");
const repo = sep === -1 ? "" : key.slice(0, sep);
if (!refreshed.includes(repo)) out.add(key);
}
return out;
});
}, [allFiles, statusByRepo, setPendingStageFiles]);
}
type RemoteOpsArgs = {
gitOps: ReturnType<typeof useGitOperations>;
repoNamesForControls: string[];
perRepoStatus: Array<{ repository_name: string; ahead: number }>;
};
/**
* Fan-out wrappers for push/pull/rebase/merge/abort so the top-bar buttons
* (which call `git.push()` / `git.pull()` etc. without a `repo` arg) hit
* every repo in multi-repo workspaces instead of silently failing at the
* workspace root (Bug 1). Single-repo workspaces and explicit-repo callers
* fall through to the underlying `gitOps` directly.
*
* Push gates on `ahead > 0` per repo so we don't push repos with nothing to
* push. Pull/Rebase/Merge/Abort fan out across every repo unconditionally.
*/
function useRemoteOpsFanOut({ gitOps, repoNamesForControls, perRepoStatus }: RemoteOpsArgs) {
// Filter out the legacy empty-name entry; it represents the workspace root
// which isn't a real git repo in multi-repo task workspaces. We only want
// to iterate the named repos.
const namedRepos = useMemo(
() => repoNamesForControls.filter((r) => r !== ""),
[repoNamesForControls],
);
const isMultiRepo = namedRepos.length > 1;
const aheadByRepo = useMemo(() => {
const m = new Map<string, number>();
for (const s of perRepoStatus) m.set(s.repository_name, s.ahead);
return m;
}, [perRepoStatus]);
const pull = useCallback(
async (rebase = false, repo?: string): Promise<GitOperationResult> => {
if (repo !== undefined || !isMultiRepo) return gitOps.pull(rebase, repo);
return fanOutAcrossRepos(namedRepos, "pull", (r) => gitOps.pull(rebase, r));
},
// eslint-disable-next-line react-hooks/exhaustive-deps -- stable fn ref
[gitOps.pull, isMultiRepo, namedRepos],
);
const push = useCallback(
async (
options?: { force?: boolean; setUpstream?: boolean },
repo?: string,
): Promise<GitOperationResult> => {
if (repo !== undefined || !isMultiRepo) return gitOps.push(options, repo);
const reposWithAhead = namedRepos.filter((r) => (aheadByRepo.get(r) ?? 0) > 0);
if (reposWithAhead.length === 0) {
return { success: true, operation: "push", output: "No commits to push" };
}
return fanOutAcrossRepos(reposWithAhead, "push", (r) => gitOps.push(options, r));
},
// eslint-disable-next-line react-hooks/exhaustive-deps -- stable fn ref
[gitOps.push, isMultiRepo, namedRepos, aheadByRepo],
);
const rebase = useCallback(
async (baseBranch: string, repo?: string): Promise<GitOperationResult> => {
if (repo !== undefined || !isMultiRepo) return gitOps.rebase(baseBranch, repo);
return fanOutAcrossRepos(namedRepos, "rebase", (r) => gitOps.rebase(baseBranch, r));
},
// eslint-disable-next-line react-hooks/exhaustive-deps -- stable fn ref
[gitOps.rebase, isMultiRepo, namedRepos],
);
const merge = useCallback(
async (baseBranch: string, repo?: string): Promise<GitOperationResult> => {
if (repo !== undefined || !isMultiRepo) return gitOps.merge(baseBranch, repo);
return fanOutAcrossRepos(namedRepos, "merge", (r) => gitOps.merge(baseBranch, r));
},
// eslint-disable-next-line react-hooks/exhaustive-deps -- stable fn ref
[gitOps.merge, isMultiRepo, namedRepos],
);
const abort = useCallback(
async (operation: "merge" | "rebase", repo?: string): Promise<GitOperationResult> => {
if (repo !== undefined || !isMultiRepo) return gitOps.abort(operation, repo);
return fanOutAcrossRepos(namedRepos, "abort", (r) => gitOps.abort(operation, r));
},
// eslint-disable-next-line react-hooks/exhaustive-deps -- stable fn ref
[gitOps.abort, isMultiRepo, namedRepos],
);
return { pull, push, rebase, merge, abort };
}
/**
* Bundles the file/multirepo derivations the parent hook needs: aggregated
* file list, staged/unstaged splits, per-path repo lookup, distinct repo
* names, and the multi-repo summary used by per-repo header buttons.
*/
function useFileDerivations(
statusByRepo: ReturnType<typeof useSessionGitStatusByRepo>,
gitStatus: ReturnType<typeof useSessionGitStatus>,
) {
const allFiles = useMemo<FileInfo[]>(
() => aggregateFilesAcrossRepos(statusByRepo, gitStatus),
[statusByRepo, gitStatus],
);
useEffect(() => {
if (!isDebug()) return;
debugDeriv("aggregate", {
path: statusByRepo.length > 0 ? "multi-repo" : "single-repo-fallback",
statusByRepoEntries: statusByRepo.map((s) => ({
repo: s.repository_name,
files: Object.keys(s.status?.files ?? {}).length,
})),
legacyGitStatusFiles: Object.keys(gitStatus?.files ?? {}).length,
allFilesCount: allFiles.length,
});
}, [statusByRepo, gitStatus, allFiles]);
const unstagedFiles = useMemo(() => allFiles.filter((f) => !f.staged), [allFiles]);
const stagedFiles = useMemo(() => allFiles.filter((f) => f.staged), [allFiles]);
const repoForPath = useMemo(() => {
const m = new Map<string, string>();
for (const f of allFiles) {
if (f.repository_name) m.set(f.path, f.repository_name);
}
return m;
}, [allFiles]);
const reposInFiles = useMemo(() => {
const seen = new Set<string>();
for (const f of allFiles) if (f.repository_name) seen.add(f.repository_name);
return Array.from(seen);
}, [allFiles]);
const { repoNamesForControls, perRepoStatus } = useMultiRepoSummary(
statusByRepo,
allFiles,
reposInFiles,
);
return {
allFiles,
unstagedFiles,
stagedFiles,
repoForPath,
reposInFiles,
repoNamesForControls,
perRepoStatus,
};
}
export function useSessionGit(sessionId: string | null | undefined): SessionGit {
const sid = sessionId ?? null;
const gitStatus = useSessionGitStatus(sid);
const statusByRepo = useSessionGitStatusByRepo(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,
unstagedFiles,
stagedFiles,
repoForPath,
reposInFiles,
repoNamesForControls,
perRepoStatus,
} = useFileDerivations(statusByRepo, gitStatus);
usePerRepoPendingClear(statusByRepo, allFiles, setPendingStageFiles);
const ahead = gitStatus?.ahead ?? 0;
const hasUnstaged = unstagedFiles.length > 0;
const hasStaged = stagedFiles.length > 0;
const hasCommits = commits.length > 0;
const stageOps = useStageDispatch({
gitOps,
repoForPath,
reposInFiles,
stagedFiles,
setPendingStageFiles,
});
const { stageAll, unstageAll, commit, stageFile, unstageFile, discard } = stageOps;
const remoteOps = useRemoteOpsFanOut({
gitOps,
repoNamesForControls,
perRepoStatus,
});
return {
branch: gitStatus?.branch ?? null,
remoteBranch: gitStatus?.remote_branch ?? null,
ahead,
behind: gitStatus?.behind ?? 0,
repoNames: repoNamesForControls,
perRepoStatus,
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: remoteOps.pull,
push: remoteOps.push,
rebase: remoteOps.rebase,
merge: remoteOps.merge,
abort: remoteOps.abort,
commit,
// stage/unstage with no paths and a `repo` arg = stage-all/unstage-all
// for that single repo (one agentctl call). Without `repo`, we fan out
// across every repo with files (multi-repo) or hit the workspace root
// (single-repo). With paths, we route to the right repo per file.
stage: (paths?: string[], repo?: string) => {
if (paths && paths.length > 0) return stageFile(paths, repo);
if (repo) return gitOps.stage(undefined, repo);
return stageAll();
},
stageFile,
stageAll,
unstage: (paths?: string[], repo?: string) => {
if (paths && paths.length > 0) return unstageFile(paths, repo);
if (repo) return gitOps.unstage(undefined, repo);
return unstageAll();
},
unstageFile,
unstageAll,
discard,
revertCommit: gitOps.revertCommit,
renameBranch: gitOps.renameBranch,
reset: gitOps.reset,
createPR: gitOps.createPR,
};
}