Files
buzz/desktop/src/features/projects/branchMutations.ts
thomaspblock e30db7028f feat(projects): support multiple repositories (#4671)
## Summary
- adopt the finalized NIP-MP project model so one project can enumerate
and switch between multiple NIP-34 repositories
- add project and repository navigation, activity summaries,
existing-repository attachment, and repository access-channel management
- preserve privacy-safe activation provenance for agent-authored
patches, pull requests, issues, and associated commits

## Test plan
- [x] Run desktop typecheck and unit tests
- [x] Run focused NIP-MP, repository access, and provenance tests
- [x] Run Rust formatting and desktop lint checks
- [x] Run the complete pre-push suite after merging current `main`
- [ ] Manually verify project creation, repository attachment,
switching, and access repair on staging
- [ ] Manually verify public-channel and private-agent origin labels on
newly created Git activity

Related: [#4695](https://github.com/block/buzz/pull/4695)

---------

Signed-off-by: Thomas Petersen <thomasp@squareup.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Co-authored-by: npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz>
Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
2026-08-04 17:30:12 -04:00

137 lines
4.2 KiB
TypeScript

import { useMutation, useQueryClient } from "@tanstack/react-query";
import * as React from "react";
import { toast } from "sonner";
import type { Repository as Project } from "@/features/projects/hooks";
import {
createProjectRemoteBranch,
deleteProjectRemoteBranch,
} from "@/shared/api/projectGit";
/** Creates a remote branch from an observed branch commit. */
export function useCreateProjectRemoteBranchMutation(
project: Project | null | undefined,
) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (input: {
sourceBranch: string;
expectedCommit: string;
newBranch: string;
}) => {
if (!project?.cloneUrls[0]) throw new Error("No project selected.");
return createProjectRemoteBranch({
cloneUrl: project.cloneUrls[0],
...input,
});
},
onSuccess: () =>
queryClient.invalidateQueries({
queryKey: ["project", project?.id ?? "none"],
}),
});
}
/** Deletes a remote branch only if it still points at the observed commit. */
export function useDeleteProjectRemoteBranchMutation(
project: Project | null | undefined,
) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (input: { branch: string; expectedCommit: string }) => {
if (!project?.cloneUrls[0]) throw new Error("No project selected.");
return deleteProjectRemoteBranch({
cloneUrl: project.cloneUrls[0],
...input,
});
},
onSuccess: () =>
queryClient.invalidateQueries({
queryKey: ["project", project?.id ?? "none"],
}),
});
}
/** Coordinates branch dialogs and refreshes around the remote mutations. */
export function useProjectBranchActions(input: {
project: Project | null | undefined;
activeBranch: string | null;
activeBranchCommit: string | null;
activeRemoteBranch: { name: string; commit: string } | null;
defaultBranch: string | null;
deleteBranchReason: string | null;
refetchRepoState: () => Promise<unknown>;
rememberBranch: (branch: { name: string; commit: string }) => void;
forgetBranch: (branch: string) => void;
selectBranch: (branch: string | null) => void;
}) {
const [createOpen, setCreateOpen] = React.useState(false);
const [deleteOpen, setDeleteOpen] = React.useState(false);
const createMutation = useCreateProjectRemoteBranchMutation(input.project);
const deleteMutation = useDeleteProjectRemoteBranchMutation(input.project);
const createBranch = createMutation.mutateAsync;
const deleteBranch = deleteMutation.mutateAsync;
const handleCreate = React.useCallback(
async (newBranch: string) => {
if (!input.activeBranch || !input.activeBranchCommit) {
throw new Error("Refresh the source branch before creating a branch.");
}
const result = await createBranch({
sourceBranch: input.activeBranch,
expectedCommit: input.activeBranchCommit,
newBranch,
});
await input.refetchRepoState();
input.rememberBranch({ name: result.branch, commit: result.commit });
input.selectBranch(result.branch);
toast.success(result.message);
},
[
createBranch,
input.activeBranch,
input.activeBranchCommit,
input.refetchRepoState,
input.rememberBranch,
input.selectBranch,
],
);
const handleDelete = React.useCallback(async () => {
if (
!input.activeBranch ||
!input.activeRemoteBranch ||
input.deleteBranchReason
) {
throw new Error(input.deleteBranchReason ?? "Choose a remote branch.");
}
const result = await deleteBranch({
branch: input.activeBranch,
expectedCommit: input.activeRemoteBranch.commit,
});
input.forgetBranch(result.branch);
input.selectBranch(input.defaultBranch);
await input.refetchRepoState();
toast.success(result.message);
}, [
deleteBranch,
input.activeBranch,
input.activeRemoteBranch,
input.defaultBranch,
input.deleteBranchReason,
input.forgetBranch,
input.refetchRepoState,
input.selectBranch,
]);
return {
createOpen,
createPending: createMutation.isPending,
deleteOpen,
deletePending: deleteMutation.isPending,
handleCreate,
handleDelete,
setCreateOpen,
setDeleteOpen,
};
}