From c60dbb696592186dc68e4731b84f077907859f74 Mon Sep 17 00:00:00 2001 From: Thomas Petersen Date: Tue, 21 Jul 2026 09:38:40 +0200 Subject: [PATCH] feat(desktop): manage project branches Let project members create remote branches and safely delete non-default branches without leaving the Projects workflow. --- desktop/src-tauri/src/commands/mod.rs | 2 + .../src/commands/project_git_branches.rs | 346 ++++++++++++++++++ .../src/commands/project_git_exec.rs | 19 +- desktop/src-tauri/src/lib.rs | 2 + .../src/features/projects/branchMutations.ts | 130 +++++++ .../projects/lib/projectBranches.test.mjs | 73 ++++ .../features/projects/lib/projectBranches.ts | 78 ++++ .../projects/ui/ProjectBranchDialogs.tsx | 207 +++++++++++ .../projects/ui/ProjectDetailScreen.tsx | 68 +++- .../projects/ui/ProjectReadmePanel.tsx | 5 + .../projects/ui/ProjectRepositoryPanel.tsx | 10 + .../projects/ui/ProjectRepositorySource.tsx | 45 +++ desktop/src/shared/api/projectGit.ts | 39 ++ desktop/src/shared/api/projectGitTypes.ts | 6 + desktop/src/shared/api/types.ts | 1 + desktop/src/testing/e2eBridge.ts | 77 ++++ desktop/tests/e2e/project-pr-review.spec.ts | 60 +++ 17 files changed, 1160 insertions(+), 8 deletions(-) create mode 100644 desktop/src-tauri/src/commands/project_git_branches.rs create mode 100644 desktop/src/features/projects/branchMutations.ts create mode 100644 desktop/src/features/projects/lib/projectBranches.test.mjs create mode 100644 desktop/src/features/projects/lib/projectBranches.ts create mode 100644 desktop/src/features/projects/ui/ProjectBranchDialogs.tsx diff --git a/desktop/src-tauri/src/commands/mod.rs b/desktop/src-tauri/src/commands/mod.rs index 49b71db929..47878ed983 100644 --- a/desktop/src-tauri/src/commands/mod.rs +++ b/desktop/src-tauri/src/commands/mod.rs @@ -35,6 +35,7 @@ mod personas; mod prevent_sleep; mod profile; mod project_git; +mod project_git_branches; mod project_git_diff; mod project_git_exec; mod project_git_workflow; @@ -86,6 +87,7 @@ pub use personas::*; pub use prevent_sleep::*; pub use profile::*; pub use project_git::*; +pub use project_git_branches::*; pub use project_git_diff::*; pub use project_git_workflow::*; pub use project_terminal::*; diff --git a/desktop/src-tauri/src/commands/project_git_branches.rs b/desktop/src-tauri/src/commands/project_git_branches.rs new file mode 100644 index 0000000000..7c566c3d7e --- /dev/null +++ b/desktop/src-tauri/src/commands/project_git_branches.rs @@ -0,0 +1,346 @@ +//! Authenticated remote branch management for Projects repositories. + +use super::project_git::first_output_line; +use super::project_git_diff::clean_commit; +use super::project_git_exec::{ + build_git_auth_config, clean_branch, run_git, validate_workspace_clone_url, GitAuthConfig, +}; +use crate::app_state::AppState; +use serde::Serialize; +use tauri::State; + +#[derive(Serialize)] +pub struct ProjectRepoBranchResult { + pub branch: String, + pub commit: String, + pub message: String, +} + +fn normalize_branch(value: &str, label: &str) -> Result { + let value = value.trim(); + if value.starts_with("refs/") && !value.starts_with("refs/heads/") { + return Err(format!("Invalid {label} branch.")); + } + let branch = + clean_branch(Some(value.to_string())).ok_or_else(|| format!("Invalid {label} branch."))?; + if branch.ends_with('.') + || branch.ends_with(".lock") + || branch.contains("//") + || branch + .split('/') + .any(|component| component.starts_with('.')) + { + return Err(format!("Invalid {label} branch.")); + } + Ok(branch) +} + +fn normalize_commit(value: &str, label: &str) -> Result { + clean_commit(Some(value.trim().to_ascii_lowercase())) + .ok_or_else(|| format!("Invalid {label} commit.")) +} + +fn remote_head_branch(output: &str) -> Option { + output.lines().find_map(|line| { + let target = line.strip_prefix("ref: ")?.strip_suffix("\tHEAD")?; + let branch = target.strip_prefix("refs/heads/")?; + clean_branch(Some(branch.to_string())) + }) +} + +fn create_remote_branch_blocking( + clone_url: &str, + source_branch: &str, + expected_commit: &str, + new_branch: &str, + auth: &GitAuthConfig, +) -> Result { + let temp_dir = tempfile::tempdir().map_err(|error| format!("create temp dir: {error}"))?; + let repo_dir = temp_dir.path().join("repo.git"); + let repo_path = repo_dir + .to_str() + .ok_or_else(|| "temporary repository path is not UTF-8".to_string())?; + run_git(&["init", "--bare", "--", repo_path], None, auth)?; + run_git( + &["remote", "add", "--", "origin", clone_url], + Some(&repo_dir), + auth, + )?; + run_git( + &[ + "fetch", + "--quiet", + "--depth=1", + "--no-tags", + "--end-of-options", + "origin", + format!("refs/heads/{source_branch}").as_str(), + ], + Some(&repo_dir), + auth, + )?; + let source_commit = run_git(&["rev-parse", "FETCH_HEAD"], Some(&repo_dir), auth) + .ok() + .and_then(|output| first_output_line(&output)) + .ok_or_else(|| "Could not resolve the source branch.".to_string())? + .to_ascii_lowercase(); + if source_commit != expected_commit { + return Err( + "The source branch changed. Refresh the repository before creating a branch." + .to_string(), + ); + } + let lease = format!("--force-with-lease=refs/heads/{new_branch}:"); + let refspec = format!("{source_commit}:refs/heads/{new_branch}"); + run_git( + &[ + "push", + lease.as_str(), + "--end-of-options", + "origin", + refspec.as_str(), + ], + Some(&repo_dir), + auth, + )?; + + Ok(ProjectRepoBranchResult { + branch: new_branch.to_string(), + commit: source_commit, + message: format!("Created branch {new_branch} from {source_branch}."), + }) +} + +fn delete_remote_branch_blocking( + clone_url: &str, + branch: &str, + expected_commit: &str, + auth: &GitAuthConfig, +) -> Result { + let head_output = run_git( + &[ + "ls-remote", + "--symref", + "--exit-code", + "--end-of-options", + clone_url, + "HEAD", + ], + None, + auth, + )?; + if remote_head_branch(&head_output).as_deref() == Some(branch) { + return Err("The repository's default branch cannot be deleted.".to_string()); + } + + let temp_dir = tempfile::tempdir().map_err(|error| format!("create temp dir: {error}"))?; + let repo_dir = temp_dir.path().join("repo.git"); + let repo_path = repo_dir + .to_str() + .ok_or_else(|| "temporary repository path is not UTF-8".to_string())?; + run_git(&["init", "--bare", "--", repo_path], None, auth)?; + run_git( + &["remote", "add", "--", "origin", clone_url], + Some(&repo_dir), + auth, + )?; + let lease = format!("--force-with-lease=refs/heads/{branch}:{expected_commit}"); + let refspec = format!(":refs/heads/{branch}"); + run_git( + &[ + "push", + lease.as_str(), + "--end-of-options", + "origin", + refspec.as_str(), + ], + Some(&repo_dir), + auth, + )?; + + Ok(ProjectRepoBranchResult { + branch: branch.to_string(), + commit: expected_commit.to_string(), + message: format!("Deleted branch {branch}."), + }) +} + +#[tauri::command] +pub async fn create_project_remote_branch( + clone_url: String, + source_branch: String, + expected_commit: String, + new_branch: String, + state: State<'_, AppState>, +) -> Result { + validate_workspace_clone_url(&clone_url, &state)?; + let source_branch = normalize_branch(&source_branch, "source")?; + let expected_commit = normalize_commit(&expected_commit, "source")?; + let new_branch = normalize_branch(&new_branch, "new")?; + if new_branch == source_branch { + return Err("The new branch must have a different name.".to_string()); + } + let auth = build_git_auth_config(&state)?; + + tauri::async_runtime::spawn_blocking(move || { + create_remote_branch_blocking( + &clone_url, + &source_branch, + &expected_commit, + &new_branch, + &auth, + ) + }) + .await + .map_err(|error| format!("branch creation task failed: {error}"))? +} + +#[tauri::command] +pub async fn delete_project_remote_branch( + clone_url: String, + branch: String, + expected_commit: String, + state: State<'_, AppState>, +) -> Result { + validate_workspace_clone_url(&clone_url, &state)?; + let branch = normalize_branch(&branch, "branch")?; + let expected_commit = normalize_commit(&expected_commit, "branch")?; + let auth = build_git_auth_config(&state)?; + + tauri::async_runtime::spawn_blocking(move || { + delete_remote_branch_blocking(&clone_url, &branch, &expected_commit, &auth) + }) + .await + .map_err(|error| format!("branch deletion task failed: {error}"))? +} + +#[cfg(test)] +mod tests { + use super::{ + create_remote_branch_blocking, delete_remote_branch_blocking, normalize_branch, + normalize_commit, remote_head_branch, + }; + use crate::commands::project_git_exec::{build_test_git_auth_config, run_git}; + + #[test] + fn branch_inputs_use_conservative_git_validation() { + assert_eq!( + normalize_branch("refs/heads/feature/demo", "source"), + Ok("feature/demo".to_string()) + ); + assert!(normalize_branch("--upload-pack=/tmp/evil", "new").is_err()); + assert!(normalize_branch("feature/../main", "new").is_err()); + assert!(normalize_branch("refs/tags/v1", "new").is_err()); + assert!(normalize_branch("feature//demo", "new").is_err()); + assert!(normalize_branch("feature/.hidden", "new").is_err()); + assert!(normalize_branch("feature/demo.lock", "new").is_err()); + } + + #[test] + fn commit_inputs_accept_sha1_and_sha256() { + assert_eq!( + normalize_commit(&"A".repeat(40), "source"), + Ok("a".repeat(40)) + ); + assert_eq!( + normalize_commit(&"B".repeat(64), "branch"), + Ok("b".repeat(64)) + ); + assert!(normalize_commit("not-a-commit", "source").is_err()); + } + + #[test] + fn remote_head_parser_only_accepts_branch_symrefs() { + assert_eq!( + remote_head_branch( + "ref: refs/heads/main\tHEAD\n0123456789012345678901234567890123456789\tHEAD\n" + ), + Some("main".to_string()) + ); + assert_eq!(remote_head_branch("0123\tHEAD\n"), None); + assert_eq!(remote_head_branch("ref: refs/tags/v1\tHEAD\n"), None); + } + + #[test] + fn remote_branch_create_and_delete_round_trip() { + let auth = build_test_git_auth_config().expect("build test git config"); + let root = tempfile::tempdir().expect("create test directory"); + let remote = root.path().join("remote.git"); + let worktree = root.path().join("worktree"); + let remote_path = remote.to_str().expect("remote path"); + let worktree_path = worktree.to_str().expect("worktree path"); + + run_git(&["init", "--bare", "--", remote_path], None, &auth).expect("init remote"); + run_git(&["init", "--", worktree_path], None, &auth).expect("init worktree"); + std::fs::write(worktree.join("README.md"), "branch test\n").expect("write fixture"); + run_git(&["add", "README.md"], Some(&worktree), &auth).expect("stage fixture"); + run_git( + &[ + "-c", + "user.name=Buzz Test", + "-c", + "user.email=test@example.com", + "commit", + "-m", + "Initial commit", + ], + Some(&worktree), + &auth, + ) + .expect("commit fixture"); + run_git(&["branch", "-M", "main"], Some(&worktree), &auth).expect("rename branch"); + run_git( + &["remote", "add", "origin", remote_path], + Some(&worktree), + &auth, + ) + .expect("add remote"); + run_git(&["push", "origin", "main"], Some(&worktree), &auth).expect("push main"); + run_git( + &[ + format!("--git-dir={remote_path}").as_str(), + "symbolic-ref", + "HEAD", + "refs/heads/main", + ], + None, + &auth, + ) + .expect("set remote HEAD"); + let commit = run_git(&["rev-parse", "HEAD"], Some(&worktree), &auth) + .expect("resolve fixture commit") + .trim() + .to_string(); + + let created = + create_remote_branch_blocking(remote_path, "main", &commit, "feature/demo", &auth) + .expect("create remote branch"); + assert_eq!(created.branch, "feature/demo"); + assert!(run_git( + &[ + format!("--git-dir={remote_path}").as_str(), + "show-ref", + "--verify", + "refs/heads/feature/demo", + ], + None, + &auth, + ) + .is_ok()); + + delete_remote_branch_blocking(remote_path, "feature/demo", &commit, &auth) + .expect("delete remote branch"); + assert!(run_git( + &[ + format!("--git-dir={remote_path}").as_str(), + "show-ref", + "--verify", + "refs/heads/feature/demo", + ], + None, + &auth, + ) + .is_err()); + assert!(delete_remote_branch_blocking(remote_path, "main", &commit, &auth).is_err()); + } +} diff --git a/desktop/src-tauri/src/commands/project_git_exec.rs b/desktop/src-tauri/src/commands/project_git_exec.rs index 6f3aa226bc..782323f682 100644 --- a/desktop/src-tauri/src/commands/project_git_exec.rs +++ b/desktop/src-tauri/src/commands/project_git_exec.rs @@ -48,6 +48,7 @@ pub(crate) struct GitAuthConfig { git_path: std::path::PathBuf, credential_helper: Option, nsec: String, + allow_file_transport: bool, } fn read_pipe_lossy(pipe: Option) -> String { @@ -156,7 +157,15 @@ fn configure_git_auth(command: &mut Command, auth: &GitAuthConfig, needs_credent ("protocol.http.allow", "always".to_string()), ("protocol.https.allow", "always".to_string()), ("protocol.ext.allow", "never".to_string()), - ("protocol.file.allow", "never".to_string()), + ( + "protocol.file.allow", + if auth.allow_file_transport { + "always" + } else { + "never" + } + .to_string(), + ), ]; if needs_credentials { let Some(cred_helper) = &auth.credential_helper else { @@ -193,9 +202,17 @@ pub(crate) fn build_git_auth_config_for_keys(keys: &Keys) -> Result Result { + let mut auth = build_git_auth_config_for_keys(&Keys::generate())?; + auth.allow_file_transport = true; + Ok(auth) +} + /// Normalizes and validates a relay-supplied branch name. Strips a /// `refs/heads/` prefix, then rejects anything outside a conservative /// character allowlist, path traversal (`..`), leading/trailing `/`, and diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index feddd4dad1..9d6424aca2 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -672,6 +672,8 @@ pub fn run() { get_project_repo_sync_status, list_project_local_repositories, clone_project_repository, + create_project_remote_branch, + delete_project_remote_branch, push_project_local_repository, pull_project_local_repository, sign_project_pull_request_review_request, diff --git a/desktop/src/features/projects/branchMutations.ts b/desktop/src/features/projects/branchMutations.ts new file mode 100644 index 0000000000..b748025e1c --- /dev/null +++ b/desktop/src/features/projects/branchMutations.ts @@ -0,0 +1,130 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import * as React from "react"; +import { toast } from "sonner"; + +import type { 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; + 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.selectBranch(result.branch); + toast.success(result.message); + }, + [ + createBranch, + input.activeBranch, + input.activeBranchCommit, + input.refetchRepoState, + 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.selectBranch(input.defaultBranch); + await input.refetchRepoState(); + toast.success(result.message); + }, [ + deleteBranch, + input.activeBranch, + input.activeRemoteBranch, + input.defaultBranch, + input.deleteBranchReason, + input.refetchRepoState, + input.selectBranch, + ]); + + return { + createOpen, + createPending: createMutation.isPending, + deleteOpen, + deletePending: deleteMutation.isPending, + handleCreate, + handleDelete, + setCreateOpen, + setDeleteOpen, + }; +} diff --git a/desktop/src/features/projects/lib/projectBranches.test.mjs b/desktop/src/features/projects/lib/projectBranches.test.mjs new file mode 100644 index 0000000000..c0b9fadb20 --- /dev/null +++ b/desktop/src/features/projects/lib/projectBranches.test.mjs @@ -0,0 +1,73 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; + +import { + normalizeProjectBranchName, + projectBranchManagementState, + projectBranchNameError, + projectBranchOptions, +} from "./projectBranches.ts"; + +test("normalizes plain and full branch refs", () => { + assert.equal(normalizeProjectBranchName(" feature/demo "), "feature/demo"); + assert.equal(normalizeProjectBranchName("refs/heads/release-1"), "release-1"); +}); + +test("rejects unsafe and invalid branch names", () => { + for (const value of [ + "--upload-pack=/tmp/evil", + "feature/../main", + "feature//demo", + "feature/.hidden", + "feature/demo.lock", + "refs/tags/v1", + "bad name", + ]) { + assert.equal(normalizeProjectBranchName(value), null, value); + } +}); + +test("reports duplicate branch names", () => { + assert.equal( + projectBranchNameError("feature/demo", ["main", "feature/demo"]), + "A branch with this name already exists.", + ); + assert.equal(projectBranchNameError("feature/new", ["main"]), null); +}); + +test("combines remote and local branch options without duplicates", () => { + assert.deepEqual( + projectBranchOptions(["main", "feature/remote"], "feature/local"), + ["main", "feature/remote", "feature/local"], + ); + assert.deepEqual(projectBranchOptions(["main"], "main"), ["main"]); +}); + +test("derives branch commits and deletion safeguards", () => { + const branches = [ + { name: "main", commit: "a".repeat(40) }, + { name: "feature/demo", commit: "b".repeat(40) }, + ]; + assert.deepEqual( + projectBranchManagementState({ + activeBranch: "feature/demo", + branches, + defaultBranch: "main", + hasOpenPullRequest: false, + }), + { + activeBranchCommit: "b".repeat(40), + activeRemoteBranch: branches[1], + deleteBranchReason: null, + }, + ); + assert.equal( + projectBranchManagementState({ + activeBranch: "main", + branches, + defaultBranch: "main", + hasOpenPullRequest: false, + }).deleteBranchReason, + "The repository's default branch cannot be deleted.", + ); +}); diff --git a/desktop/src/features/projects/lib/projectBranches.ts b/desktop/src/features/projects/lib/projectBranches.ts new file mode 100644 index 0000000000..56af97f9eb --- /dev/null +++ b/desktop/src/features/projects/lib/projectBranches.ts @@ -0,0 +1,78 @@ +const BRANCH_CHARACTERS = /^[A-Za-z0-9/_.-]+$/; + +/** Normalize a branch name using the native command's conservative rules. */ +export function normalizeProjectBranchName(value: string): string | null { + const trimmed = value.trim(); + if (trimmed.startsWith("refs/") && !trimmed.startsWith("refs/heads/")) { + return null; + } + const branch = trimmed.replace(/^refs\/heads\//, ""); + if ( + !branch || + branch.startsWith("-") || + branch.startsWith("/") || + branch.endsWith("/") || + branch.endsWith(".") || + branch.endsWith(".lock") || + branch.includes("..") || + branch.includes("//") || + !BRANCH_CHARACTERS.test(branch) || + branch.split("/").some((component) => component.startsWith(".")) + ) { + return null; + } + return branch; +} + +export function projectBranchNameError( + value: string, + existingBranches: string[], +): string | null { + const branch = normalizeProjectBranchName(value); + if (!branch) return "Enter a valid Git branch name."; + if (existingBranches.includes(branch)) { + return "A branch with this name already exists."; + } + return null; +} + +export function projectBranchOptions( + remoteBranches: string[], + localBranch?: string | null, +): string[] { + return [ + ...new Set( + [...remoteBranches, localBranch].filter((branch): branch is string => + Boolean(branch), + ), + ), + ]; +} + +export function projectBranchManagementState(input: { + activeBranch: string | null; + defaultBranch: string | null; + branches: Array<{ name: string; commit: string }>; + remoteBranch?: string | null; + remoteHead?: string | null; + snapshotCommit?: string | null; + hasOpenPullRequest: boolean; +}) { + const activeRemoteBranch = + input.branches.find((branch) => branch.name === input.activeBranch) ?? null; + const activeBranchCommit = + activeRemoteBranch?.commit ?? + (input.remoteBranch === input.activeBranch ? input.remoteHead : null) ?? + input.snapshotCommit ?? + null; + const deleteBranchReason = !input.activeBranch + ? "Choose a branch first." + : input.activeBranch === input.defaultBranch + ? "The repository's default branch cannot be deleted." + : !activeRemoteBranch + ? "Only a published remote branch can be deleted." + : input.hasOpenPullRequest + ? "Close the branch's pull request before deleting it." + : null; + return { activeBranchCommit, activeRemoteBranch, deleteBranchReason }; +} diff --git a/desktop/src/features/projects/ui/ProjectBranchDialogs.tsx b/desktop/src/features/projects/ui/ProjectBranchDialogs.tsx new file mode 100644 index 0000000000..868f5a1849 --- /dev/null +++ b/desktop/src/features/projects/ui/ProjectBranchDialogs.tsx @@ -0,0 +1,207 @@ +import { Loader2 } from "lucide-react"; +import * as React from "react"; + +import { + normalizeProjectBranchName, + projectBranchNameError, +} from "@/features/projects/lib/projectBranches"; +import { + AlertDialog, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/shared/ui/alert-dialog"; +import { Button } from "@/shared/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/shared/ui/dialog"; +import { Input } from "@/shared/ui/input"; + +function errorMessage(error: unknown, fallback: string) { + return error instanceof Error ? error.message : fallback; +} + +export function CreateProjectBranchDialog({ + existingBranches, + onCreate, + onOpenChange, + open, + pending, + sourceBranch, + sourceCommit, +}: { + existingBranches: string[]; + onCreate: (branch: string) => Promise; + onOpenChange: (open: boolean) => void; + open: boolean; + pending: boolean; + sourceBranch: string; + sourceCommit: string | null; +}) { + const [branchName, setBranchName] = React.useState(""); + const [submitError, setSubmitError] = React.useState(null); + const validationError = projectBranchNameError(branchName, existingBranches); + + React.useEffect(() => { + if (!open) return; + setBranchName(""); + setSubmitError(null); + }, [open]); + + async function handleSubmit(event: React.FormEvent) { + event.preventDefault(); + const branch = normalizeProjectBranchName(branchName); + if (!branch || validationError || !sourceCommit) return; + setSubmitError(null); + try { + await onCreate(branch); + onOpenChange(false); + } catch (error) { + setSubmitError(errorMessage(error, "Failed to create branch.")); + } + } + + return ( + { + if (!pending) onOpenChange(nextOpen); + }} + open={open} + > + +
+ + Create branch + + Create a remote branch from{" "} + {sourceBranch} + {sourceCommit ? ` at ${sourceCommit.slice(0, 7)}` : ""}. + + + + {branchName && validationError ? ( +

{validationError}

+ ) : null} + {!sourceCommit ? ( +

+ Refresh the repository before creating a branch. +

+ ) : null} + {submitError ? ( +

{submitError}

+ ) : null} + + + + +
+
+
+ ); +} + +export function DeleteProjectBranchDialog({ + branch, + onDelete, + onOpenChange, + open, + pending, +}: { + branch: string; + onDelete: () => Promise; + onOpenChange: (open: boolean) => void; + open: boolean; + pending: boolean; +}) { + const [submitError, setSubmitError] = React.useState(null); + + React.useEffect(() => { + if (open) setSubmitError(null); + }, [open]); + + async function handleDelete() { + setSubmitError(null); + try { + await onDelete(); + onOpenChange(false); + } catch (error) { + setSubmitError(errorMessage(error, "Failed to delete branch.")); + } + } + + return ( + { + if (!pending) onOpenChange(nextOpen); + }} + open={open} + > + + + Delete branch? + + Delete the remote branch{" "} + {branch}. This + cannot be undone and may be rejected by repository protection rules. + + + {submitError ? ( +

{submitError}

+ ) : null} + + + + + + +
+
+ ); +} diff --git a/desktop/src/features/projects/ui/ProjectDetailScreen.tsx b/desktop/src/features/projects/ui/ProjectDetailScreen.tsx index f67d535513..6e50cf879b 100644 --- a/desktop/src/features/projects/ui/ProjectDetailScreen.tsx +++ b/desktop/src/features/projects/ui/ProjectDetailScreen.tsx @@ -28,6 +28,7 @@ import { usePullProjectLocalRepositoryMutation, usePushProjectLocalRepositoryMutation, } from "@/features/projects/repoSyncHooks"; +import { useProjectBranchActions } from "@/features/projects/branchMutations"; import { useUpdateProjectPullRequestMutation } from "@/features/projects/pullRequestMutations"; import { useCreateProjectIssueMutation } from "@/features/projects/issueMutations"; import { useProfileQuery, useUsersBatchQuery } from "@/features/profile/hooks"; @@ -60,6 +61,10 @@ import { useCommunities } from "@/features/communities/useCommunities"; import { useProjectCommitDiffQuery } from "@/features/projects/useProjectCommitDiff"; import { useGitIdentityQuery } from "@/features/projects/useGitIdentity"; import type { ViewerGitIdentity } from "@/features/projects/lib/projectContributorMatching"; +import { + projectBranchManagementState, + projectBranchOptions, +} from "@/features/projects/lib/projectBranches"; import { normalizeRepositoryUrl } from "@/features/projects/lib/projectsViewHelpers"; import { WorkspaceTabs } from "./ProjectWorkspaceTabs"; import type { RepoSourceHeaderControls } from "./ProjectRepositorySource"; @@ -68,6 +73,10 @@ import { useOpenProjectTerminal, } from "./useOpenProjectTerminal"; import type { CreateIssueDialogInput } from "./CreateIssueDialog"; +import { + CreateProjectBranchDialog, + DeleteProjectBranchDialog, +} from "./ProjectBranchDialogs"; import { projectPeople, pushPullTitle, @@ -261,15 +270,39 @@ export function ProjectDetailScreen(props: ProjectDetailScreenProps) { ? localRepoDiffQuery.isLoading : repoDiffQuery.isLoading; const branchOptionsWithLocal = React.useMemo( - () => [ - ...new Set( - [...branchOptions, repoSyncStatusQuery.data?.localBranch].filter( - (branch): branch is string => Boolean(branch), - ), + () => + projectBranchOptions( + branchOptions, + repoSyncStatusQuery.data?.localBranch, ), - ], [branchOptions, repoSyncStatusQuery.data?.localBranch], ); + const defaultBranch = + repoStateQuery.data?.head ?? project?.defaultBranch ?? null; + const { activeBranchCommit, activeRemoteBranch, deleteBranchReason } = + projectBranchManagementState({ + activeBranch, + branches: repoStateQuery.data?.branches ?? [], + defaultBranch, + hasOpenPullRequest: (pullRequestsQuery.data ?? []).some( + (pullRequest) => + pullRequest.branchName === activeBranch && + (pullRequest.status === "Open" || pullRequest.status === "Draft"), + ), + remoteBranch: repoSyncStatusQuery.data?.remoteBranch, + remoteHead: repoSyncStatusQuery.data?.remoteHead, + snapshotCommit: repoSnapshotQuery.data?.latestCommit?.hash, + }); + const branchActions = useProjectBranchActions({ + activeBranch, + activeBranchCommit, + activeRemoteBranch, + defaultBranch, + deleteBranchReason, + project, + refetchRepoState: repoStateQuery.refetch, + selectBranch: setSelectedBranch, + }); const handleFetchRepo = React.useCallback(async () => { const results = await Promise.all([ repoSnapshotQuery.refetch(), @@ -292,6 +325,12 @@ export function ProjectDetailScreen(props: ProjectDetailScreenProps) { branch: activeBranch ?? "", branchOptions: branchOptionsWithLocal, onBranchChange: setSelectedBranch, + onCreateBranch: () => branchActions.setCreateOpen(true), + createBranchDisabled: branchActions.createPending || !activeBranchCommit, + onDeleteBranch: () => branchActions.setDeleteOpen(true), + deleteBranchDisabled: + branchActions.deletePending || Boolean(deleteBranchReason), + deleteBranchTitle: deleteBranchReason ?? "Delete this remote branch", source: repoSource, onSourceChange: setRepoSource, localDisabled: @@ -568,7 +607,6 @@ export function ProjectDetailScreen(props: ProjectDetailScreenProps) { repoStateQuery, repoSyncStatusQuery, ]); - const openTerminal = useOpenProjectTerminal(activeCommunity?.reposDir); const handleOpenTerminal = React.useCallback(() => { if (!project) return Promise.resolve(); @@ -711,6 +749,22 @@ export function ProjectDetailScreen(props: ProjectDetailScreenProps) { return ( + +
diff --git a/desktop/src/features/projects/ui/ProjectRepositoryPanel.tsx b/desktop/src/features/projects/ui/ProjectRepositoryPanel.tsx index 575fdc268b..40053b5b04 100644 --- a/desktop/src/features/projects/ui/ProjectRepositoryPanel.tsx +++ b/desktop/src/features/projects/ui/ProjectRepositoryPanel.tsx @@ -724,7 +724,12 @@ export function RepositoryFilesPanel({ branch={sourceControls.branch} branchOptions={sourceControls.branchOptions} compact + createBranchDisabled={sourceControls.createBranchDisabled} + deleteBranchDisabled={sourceControls.deleteBranchDisabled} + deleteBranchTitle={sourceControls.deleteBranchTitle} onBranchChange={sourceControls.onBranchChange} + onCreateBranch={sourceControls.onCreateBranch} + onDeleteBranch={sourceControls.onDeleteBranch} />
@@ -757,7 +762,12 @@ export function RepositoryFilesPanel({ branch={sourceControls.branch} branchOptions={sourceControls.branchOptions} compact + createBranchDisabled={sourceControls.createBranchDisabled} + deleteBranchDisabled={sourceControls.deleteBranchDisabled} + deleteBranchTitle={sourceControls.deleteBranchTitle} onBranchChange={sourceControls.onBranchChange} + onCreateBranch={sourceControls.onCreateBranch} + onDeleteBranch={sourceControls.onDeleteBranch} /> ) : ( diff --git a/desktop/src/features/projects/ui/ProjectRepositorySource.tsx b/desktop/src/features/projects/ui/ProjectRepositorySource.tsx index 32134793e9..99e6a76545 100644 --- a/desktop/src/features/projects/ui/ProjectRepositorySource.tsx +++ b/desktop/src/features/projects/ui/ProjectRepositorySource.tsx @@ -5,7 +5,9 @@ import { GitBranch, HardDrive, Loader2, + Plus, RefreshCw, + Trash2, UploadCloud, } from "lucide-react"; @@ -16,6 +18,7 @@ import { DropdownMenuItem, DropdownMenuRadioGroup, DropdownMenuRadioItem, + DropdownMenuSeparator, DropdownMenuTrigger, } from "@/shared/ui/dropdown-menu"; import { PROJECT_PANEL_ACTION_BUTTON_CLASS } from "./projectPanelStyles"; @@ -25,13 +28,23 @@ export function RepositoryBranchDropdown({ branch, branchOptions, compact, + createBranchDisabled, + deleteBranchDisabled, + deleteBranchTitle, onBranchChange, + onCreateBranch, + onDeleteBranch, }: { branch: string; branchOptions: string[]; /** Smaller trigger for inline headers. */ compact?: boolean; + createBranchDisabled?: boolean; + deleteBranchDisabled?: boolean; + deleteBranchTitle?: string; onBranchChange: (branch: string) => void; + onCreateBranch?: () => void; + onDeleteBranch?: () => void; }) { const selectableBranches = branchOptions.length > 0 ? branchOptions : [branch]; @@ -68,6 +81,33 @@ export function RepositoryBranchDropdown({ ))} + {onCreateBranch || onDeleteBranch ? ( + <> + + {onCreateBranch ? ( + + + Create branch… + + ) : null} + {onDeleteBranch ? ( + + + Delete {branch} + + ) : null} + + ) : null} ); @@ -78,6 +118,11 @@ export type RepoSourceHeaderControls = { branch: string; branchOptions: string[]; onBranchChange: (branch: string) => void; + onCreateBranch?: () => void; + createBranchDisabled?: boolean; + onDeleteBranch?: () => void; + deleteBranchDisabled?: boolean; + deleteBranchTitle?: string; source: "remote" | "local"; onSourceChange: (source: "remote" | "local") => void; localDisabled: boolean; diff --git a/desktop/src/shared/api/projectGit.ts b/desktop/src/shared/api/projectGit.ts index a69a58260b..5ea4f70745 100644 --- a/desktop/src/shared/api/projectGit.ts +++ b/desktop/src/shared/api/projectGit.ts @@ -1,6 +1,7 @@ import type { ProjectLocalRepository, ProjectLocalRepoSnapshot, + ProjectRepoBranchResult, ProjectRepoCloneResult, ProjectRepoDiff, ProjectRepoMergeResult, @@ -96,6 +97,12 @@ type RawProjectRepoPullResult = { message: string; }; +type RawProjectRepoBranchResult = { + branch: string; + commit: string; + message: string; +}; + type RawProjectRepoDiffFile = { path: string; additions: number; @@ -426,6 +433,38 @@ export async function cloneProjectRepository(input: { }); } +export async function createProjectRemoteBranch(input: { + cloneUrl: string; + sourceBranch: string; + expectedCommit: string; + newBranch: string; +}): Promise { + return invokeTauri( + "create_project_remote_branch", + { + cloneUrl: input.cloneUrl, + sourceBranch: input.sourceBranch, + expectedCommit: input.expectedCommit, + newBranch: input.newBranch, + }, + ); +} + +export async function deleteProjectRemoteBranch(input: { + cloneUrl: string; + branch: string; + expectedCommit: string; +}): Promise { + return invokeTauri( + "delete_project_remote_branch", + { + cloneUrl: input.cloneUrl, + branch: input.branch, + expectedCommit: input.expectedCommit, + }, + ); +} + type RawProjectRepoMergeResult = { message: string; merge_commit: string; diff --git a/desktop/src/shared/api/projectGitTypes.ts b/desktop/src/shared/api/projectGitTypes.ts index fb9cd03f3c..ce57aff439 100644 --- a/desktop/src/shared/api/projectGitTypes.ts +++ b/desktop/src/shared/api/projectGitTypes.ts @@ -93,6 +93,12 @@ export type ProjectRepoCloneResult = { message: string; }; +export type ProjectRepoBranchResult = { + branch: string; + commit: string; + message: string; +}; + export type ProjectRepoMergeResult = { message: string; mergeCommit: string; diff --git a/desktop/src/shared/api/types.ts b/desktop/src/shared/api/types.ts index 7834794d25..17d31f95ac 100644 --- a/desktop/src/shared/api/types.ts +++ b/desktop/src/shared/api/types.ts @@ -190,6 +190,7 @@ export type UserStatusLookup = Record; export type { ProjectLocalRepository, ProjectLocalRepoSnapshot, + ProjectRepoBranchResult, ProjectRepoCommit, ProjectRepoContributor, ProjectRepoCloneResult, diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts index 5d0505a308..c252d541f2 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -36,6 +36,7 @@ import { KIND_MEMBER_ADDED_NOTIFICATION, KIND_MEMBER_REMOVED_NOTIFICATION, KIND_REPO_ANNOUNCEMENT, + KIND_REPO_STATE, KIND_STREAM_MESSAGE_EDIT, KIND_SYSTEM_MESSAGE, KIND_TEXT_NOTE, @@ -4721,6 +4722,7 @@ const MOCK_PROJECT_SUBJECTS = [ const MOCK_PROJECT_KINDS = new Set([ KIND_REPO_ANNOUNCEMENT, + KIND_REPO_STATE, KIND_GIT_PATCH, KIND_GIT_PULL_REQUEST, KIND_GIT_PR_UPDATE, @@ -4775,6 +4777,20 @@ function buildMockProjectEvents(): RelayEvent[] { `mock-project-${seed.dtag}`.replace(/[^a-zA-Z0-9]/g, ""), ), ); + events.push( + createMockEvent( + KIND_REPO_STATE, + "", + [ + ["d", seed.dtag], + ["HEAD", "ref: refs/heads/main"], + ["refs/heads/main", "0123456789abcdef0123456789abcdef01234567"], + ], + owner, + now - projectIndex, + `mock-repo-state-${seed.dtag}`.replace(/[^a-zA-Z0-9]/g, ""), + ), + ); for (let dayOffset = historyDays; dayOffset >= 0; dayOffset -= 1) { // Roughly half the days are quiet; busy days scale with activityLevel. @@ -9372,6 +9388,67 @@ export function maybeInstallE2eTauriMocks() { message: "Cloned repository.", }; } + case "create_project_remote_branch": { + const input = payload as { + cloneUrl: string; + expectedCommit: string; + newBranch: string; + sourceBranch: string; + }; + const dtag = new URL(input.cloneUrl).pathname + .split("/") + .filter(Boolean) + .at(-1) + ?.replace(/\.git$/, ""); + const repoState = getMockProjectEventStore().find( + (event) => + event.kind === KIND_REPO_STATE && + event.tags.some((tag) => tag[0] === "d" && tag[1] === dtag), + ); + if (repoState) { + repoState.tags = repoState.tags.filter( + (tag) => tag[0] !== `refs/heads/${input.newBranch}`, + ); + repoState.tags.push([ + `refs/heads/${input.newBranch}`, + input.expectedCommit, + ]); + repoState.created_at = Math.floor(Date.now() / 1000); + } + return { + branch: input.newBranch, + commit: input.expectedCommit, + message: `Created branch ${input.newBranch} from ${input.sourceBranch}.`, + }; + } + case "delete_project_remote_branch": { + const input = payload as { + branch: string; + cloneUrl: string; + expectedCommit: string; + }; + const dtag = new URL(input.cloneUrl).pathname + .split("/") + .filter(Boolean) + .at(-1) + ?.replace(/\.git$/, ""); + const repoState = getMockProjectEventStore().find( + (event) => + event.kind === KIND_REPO_STATE && + event.tags.some((tag) => tag[0] === "d" && tag[1] === dtag), + ); + if (repoState) { + repoState.tags = repoState.tags.filter( + (tag) => tag[0] !== `refs/heads/${input.branch}`, + ); + repoState.created_at = Math.floor(Date.now() / 1000); + } + return { + branch: input.branch, + commit: input.expectedCommit, + message: `Deleted branch ${input.branch}.`, + }; + } case "sign_project_pull_request_review_request": { const { input } = payload as { input: { diff --git a/desktop/tests/e2e/project-pr-review.spec.ts b/desktop/tests/e2e/project-pr-review.spec.ts index c16324a059..b419d879aa 100644 --- a/desktop/tests/e2e/project-pr-review.spec.ts +++ b/desktop/tests/e2e/project-pr-review.spec.ts @@ -708,6 +708,66 @@ test("project without a checkout offers fetch feedback and dropdown cloning", as expect(commands).toContain("clone_project_repository"); }); +test("project branches can be created from the selected remote branch", async ({ + page, +}) => { + await enableProjectsFeature(page); + await installMockBridge(page); + await openBuzzProject(page); + + await page.getByRole("button", { name: /main/ }).click(); + await page.getByTestId("project-create-branch").click(); + await page + .getByTestId("project-create-branch-name") + .fill("feature/branch-management"); + await page.getByTestId("project-create-branch-submit").click(); + + await expect( + page.getByText("Created branch feature/branch-management from main.", { + exact: true, + }), + ).toBeVisible(); + await expect( + page.getByRole("button", { name: /feature\/branch-management/ }), + ).toBeVisible(); + const commands = await page.evaluate( + () => window.__BUZZ_E2E_COMMANDS__ ?? [], + ); + expect(commands).toContain("create_project_remote_branch"); +}); + +test("project branches can be deleted but the default branch cannot", async ({ + page, +}) => { + await enableProjectsFeature(page); + await installMockBridge(page); + await openBuzzProject(page); + + await page.getByRole("button", { name: /main/ }).click(); + await expect(page.getByTestId("project-delete-branch")).toBeDisabled(); + await page.getByTestId("project-create-branch").click(); + await page + .getByTestId("project-create-branch-name") + .fill("feature/delete-me"); + await page.getByTestId("project-create-branch-submit").click(); + await expect( + page.getByRole("button", { name: /feature\/delete-me/ }), + ).toBeVisible(); + await page.getByRole("button", { name: /feature\/delete-me/ }).click(); + await page.getByTestId("project-delete-branch").click(); + await expect(page.getByTestId("project-delete-branch-dialog")).toBeVisible(); + await page.getByTestId("project-delete-branch-submit").click(); + + await expect( + page.getByText("Deleted branch feature/delete-me.", { exact: true }), + ).toBeVisible(); + await expect(page.getByRole("button", { name: /main/ })).toBeVisible(); + const commands = await page.evaluate( + () => window.__BUZZ_E2E_COMMANDS__ ?? [], + ); + expect(commands).toContain("delete_project_remote_branch"); +}); + test("pushed local branch can open a pull request", async ({ page }) => { await enableProjectsFeature(page); await page.addInitScript(() => {