Files
kandev/apps/web/hooks/domains/session/use-session-git.ts
T
Kandev Agent 9e8079de69 feat(multi-repo): per-repo git ops, unified branch endpoint, chip-style task create
Bundles a string of multi-repo fixes and refactors that came up while
testing the feature end-to-end:

- workspace_path stored on multi-repo TaskEnvironment was one level too
  high, so agentctl booted with the wrong WorkDir on cold start; fixed
  in resolveTaskEnvWorkspacePath + GetWorkspaceInfoForSession.
- Stage/Unstage/Discard/Commit/Revert/Reset/Amend now thread `repo`
  through agentctl client → WS handler → frontend hook → SessionGit
  fan-out, so the right git repo is targeted (was running at the task
  root, which isn't a git repo for multi-repo tasks).
- agentctl GitLog is multi-repo aware: when no `repo` is specified and
  per-repo trackers exist, fans out one log per repo and merges sorted
  newest-first with RepositoryName stamped on each commit.
- Per-repo grouping for files (Changes panel) and commits, with
  collapsible group headers; commits section defaults to collapsed.
  Algorithm extracted into one generic groupByRepositoryName helper
  (was copy-pasted three times).
- Task creation dialog rewritten around a unified `fs.repositories[]`
  list — no more "primary vs extras" split. Chip-style row with one
  pill per repo + branch, "+" to add, x to remove, GitHub URL toggle
  inline. Discovered (on-machine) repos appear in any chip's dropdown.
  Removes ~6 fields and 3 helpers from the dialog state.
- Branch listing endpoints collapsed to one:
  GET /workspaces/:id/branches?repository_id=X | path=Y
  Frontend collapses to a single useBranches hook with a tagged-union
  source. Drops two HTTP routes, two service methods, two API client
  funcs, and one whole hook file.

Tests added across the chain: workspace_path resolver, sort-commits-
by-date, manager poll-mode propagation, RepoSubpaths, chip grouping
+ collapse + per-repo isLatest, group-by-repo helper, dialog payload
builder + chip wiring + discovered-repo flow.
2026-04-26 21:23:48 +01:00

374 lines
14 KiB
TypeScript

"use client";
import { useState, useCallback, useEffect, useMemo } 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 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>;
// 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) => 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>;
};
/**
* 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) : [];
}
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());
// Multi-repo: aggregate files from every repo's per-repo status, stamping
// each FileInfo with its repository_name so downstream UI can group them.
// Single-repo (or repo-less) sessions fall back to the legacy single status.
const allFiles = useMemo<FileInfo[]>(
() => aggregateFilesAcrossRepos(statusByRepo, gitStatus),
[statusByRepo, 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;
// Multi-repo path-to-repo lookup: agentctl's stage/unstage/discard endpoints
// need an explicit repo subpath (the repo dir basename). Without it the op
// runs at the workspace root which, for multi-repo task workspaces, isn't
// a git repo and silently no-ops.
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]);
// List of distinct repo names present in the current files; empty when single-repo.
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 groupPathsByRepo = useCallback(
(paths: string[]): Map<string, string[]> => groupPathsByRepoName(paths, repoForPath),
[repoForPath],
);
// Fan out a stage-all / unstage-all (no paths) across every repo when multi-repo.
// Returns the last op's result (UI only checks success/error of the most recent).
const runOnAllRepos = useCallback(
async (op: (paths: string[] | undefined, repo?: string) => Promise<GitOperationResult>) => {
if (reposInFiles.length <= 1) {
return op(undefined, reposInFiles[0]);
}
let last: GitOperationResult | undefined;
for (const repo of reposInFiles) {
last = await op(undefined, repo);
}
return last as GitOperationResult;
},
[reposInFiles],
);
const stageAll = useCallback(
async () => runOnAllRepos((paths, repo) => gitOps.stage(paths, repo)),
// eslint-disable-next-line react-hooks/exhaustive-deps -- depend on stable fn ref, not the whole gitOps object
[runOnAllRepos, gitOps.stage],
);
const unstageAll = useCallback(
async () => runOnAllRepos((paths, repo) => gitOps.unstage(paths, repo)),
// eslint-disable-next-line react-hooks/exhaustive-deps -- depend on stable fn ref, not the whole gitOps object
[runOnAllRepos, gitOps.unstage],
);
// Multi-repo commit: when the caller doesn't pin a repo, commit each repo
// that has staged files separately (with the same message). Committing at
// the workspace root for a multi-repo task fails because the task root
// isn't itself a git repo — that's the "exit 1" the user saw.
const commit = useCallback(
async (
message: string,
stageAll: boolean = true,
amend: boolean = false,
repo?: string,
): Promise<GitOperationResult> => {
if (repo !== undefined) {
return gitOps.commit(message, stageAll, amend, repo || undefined);
}
const reposWithStaged = Array.from(
new Set(
stagedFiles
.map((f) => f.repository_name)
.filter((n): n is string => Boolean(n)),
),
);
// Single-repo (or repo-less): one call, no repo arg.
if (reposWithStaged.length === 0) {
return gitOps.commit(message, stageAll, amend);
}
// Multi-repo: one commit per repo with staged changes. Stop on first
// failure so the user sees the actual error instead of cascading ones.
let last: GitOperationResult | undefined;
for (const r of reposWithStaged) {
last = await gitOps.commit(message, stageAll, amend, r);
if (!last.success) return last;
}
return last as GitOperationResult;
},
// eslint-disable-next-line react-hooks/exhaustive-deps -- depend on stable fn ref, not the whole gitOps object
[gitOps.commit, stagedFiles],
);
// runPerRepo invokes `op` once per repo bucket. When the caller supplies an
// explicit `repo`, all paths route there as a single call (skipping the
// path → repo lookup that can collide on same-named files). Otherwise paths
// are split by repository_name from allFiles.
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 stageFile = useCallback(
async (paths: string[], repo?: string) => {
for (const p of paths) setPendingStageFiles((prev) => new Set(prev).add(p));
try {
return await runPerRepo(paths, repo, (rp, r) => gitOps.stage(rp, r));
} 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, runPerRepo],
);
const unstageFile = useCallback(
async (paths: string[], repo?: string) => {
for (const p of paths) setPendingStageFiles((prev) => new Set(prev).add(p));
try {
return await runPerRepo(paths, repo, (rp, r) => gitOps.unstage(rp, r));
} 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, runPerRepo],
);
const discard = useCallback(
async (paths?: string[], repo?: string) => {
if (!paths || paths.length === 0) {
// Discard with no paths is unsupported by the API; passthrough to surface the error.
return gitOps.discard(paths, repo);
}
return runPerRepo(paths, repo, (rp, r) => gitOps.discard(rp, r));
},
// eslint-disable-next-line react-hooks/exhaustive-deps -- depend on stable fn ref, not the whole gitOps object
[gitOps.discard, runPerRepo],
);
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,
// stage/unstage route through the fan-out wrappers so multi-repo workspaces
// hit the right repo subpath in agentctl. Empty/undefined paths = stage all.
stage: (paths?: string[], repo?: string) =>
paths && paths.length > 0 ? stageFile(paths, repo) : stageAll(),
stageFile,
stageAll,
unstage: (paths?: string[], repo?: string) =>
paths && paths.length > 0 ? unstageFile(paths, repo) : unstageAll(),
unstageFile,
unstageAll,
discard,
revertCommit: gitOps.revertCommit,
renameBranch: gitOps.renameBranch,
reset: gitOps.reset,
createPR: gitOps.createPR,
};
}