feat(desktop): manage project branches (#2212)

This commit is contained in:
thomaspblock
2026-07-21 10:16:02 +02:00
committed by GitHub
parent 4de3e04119
commit b2c38c775b
17 changed files with 1160 additions and 8 deletions
+2
View File
@@ -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::*;
@@ -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<String, String> {
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<String, String> {
clean_commit(Some(value.trim().to_ascii_lowercase()))
.ok_or_else(|| format!("Invalid {label} commit."))
}
fn remote_head_branch(output: &str) -> Option<String> {
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<ProjectRepoBranchResult, 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,
)?;
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<ProjectRepoBranchResult, String> {
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<ProjectRepoBranchResult, String> {
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<ProjectRepoBranchResult, String> {
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());
}
}
@@ -48,6 +48,7 @@ pub(crate) struct GitAuthConfig {
git_path: std::path::PathBuf,
credential_helper: Option<std::path::PathBuf>,
nsec: String,
allow_file_transport: bool,
}
fn read_pipe_lossy(pipe: Option<impl Read>) -> 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<GitAuthConfi
git_path,
credential_helper,
nsec,
allow_file_transport: false,
})
}
#[cfg(test)]
pub(crate) fn build_test_git_auth_config() -> Result<GitAuthConfig, String> {
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
+2
View File
@@ -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,
@@ -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<unknown>;
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,
};
}
@@ -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.",
);
});
@@ -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 };
}
@@ -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<void>;
onOpenChange: (open: boolean) => void;
open: boolean;
pending: boolean;
sourceBranch: string;
sourceCommit: string | null;
}) {
const [branchName, setBranchName] = React.useState("");
const [submitError, setSubmitError] = React.useState<string | null>(null);
const validationError = projectBranchNameError(branchName, existingBranches);
React.useEffect(() => {
if (!open) return;
setBranchName("");
setSubmitError(null);
}, [open]);
async function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
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 (
<Dialog
onOpenChange={(nextOpen) => {
if (!pending) onOpenChange(nextOpen);
}}
open={open}
>
<DialogContent data-testid="project-create-branch-dialog">
<form className="space-y-5" onSubmit={handleSubmit}>
<DialogHeader>
<DialogTitle>Create branch</DialogTitle>
<DialogDescription>
Create a remote branch from{" "}
<span className="font-mono text-foreground">{sourceBranch}</span>
{sourceCommit ? ` at ${sourceCommit.slice(0, 7)}` : ""}.
</DialogDescription>
</DialogHeader>
<label
className="block space-y-2 text-sm font-medium"
htmlFor="project-create-branch-name"
>
<span>Branch name</span>
<Input
autoFocus
data-testid="project-create-branch-name"
disabled={pending}
id="project-create-branch-name"
onChange={(event) => setBranchName(event.target.value)}
placeholder="feature/my-change"
value={branchName}
/>
</label>
{branchName && validationError ? (
<p className="text-sm text-destructive">{validationError}</p>
) : null}
{!sourceCommit ? (
<p className="text-sm text-destructive">
Refresh the repository before creating a branch.
</p>
) : null}
{submitError ? (
<p className="text-sm text-destructive">{submitError}</p>
) : null}
<DialogFooter>
<Button
disabled={pending}
onClick={() => onOpenChange(false)}
type="button"
variant="outline"
>
Cancel
</Button>
<Button
data-testid="project-create-branch-submit"
disabled={pending || Boolean(validationError) || !sourceCommit}
type="submit"
>
{pending ? <Loader2 className="h-4 w-4 animate-spin" /> : null}
{pending ? "Creating…" : "Create branch"}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}
export function DeleteProjectBranchDialog({
branch,
onDelete,
onOpenChange,
open,
pending,
}: {
branch: string;
onDelete: () => Promise<void>;
onOpenChange: (open: boolean) => void;
open: boolean;
pending: boolean;
}) {
const [submitError, setSubmitError] = React.useState<string | null>(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 (
<AlertDialog
onOpenChange={(nextOpen) => {
if (!pending) onOpenChange(nextOpen);
}}
open={open}
>
<AlertDialogContent data-testid="project-delete-branch-dialog">
<AlertDialogHeader>
<AlertDialogTitle>Delete branch?</AlertDialogTitle>
<AlertDialogDescription>
Delete the remote branch{" "}
<span className="font-mono text-foreground">{branch}</span>. This
cannot be undone and may be rejected by repository protection rules.
</AlertDialogDescription>
</AlertDialogHeader>
{submitError ? (
<p className="text-sm text-destructive">{submitError}</p>
) : null}
<AlertDialogFooter>
<AlertDialogCancel asChild>
<Button disabled={pending} type="button" variant="outline">
Cancel
</Button>
</AlertDialogCancel>
<Button
data-testid="project-delete-branch-submit"
disabled={pending}
onClick={() => void handleDelete()}
type="button"
variant="destructive"
>
{pending ? <Loader2 className="h-4 w-4 animate-spin" /> : null}
{pending ? "Deleting…" : "Delete branch"}
</Button>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
}
@@ -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 (
<ProfilePanelProvider onOpenProfilePanel={handleOpenProfilePanel}>
<CreateProjectBranchDialog
existingBranches={branchOptionsWithLocal}
onCreate={branchActions.handleCreate}
onOpenChange={branchActions.setCreateOpen}
open={branchActions.createOpen}
pending={branchActions.createPending}
sourceBranch={activeBranch ?? ""}
sourceCommit={activeBranchCommit}
/>
<DeleteProjectBranchDialog
branch={activeBranch ?? ""}
onDelete={branchActions.handleDelete}
onOpenChange={branchActions.setDeleteOpen}
open={branchActions.deleteOpen}
pending={branchActions.deletePending}
/>
<div className="flex min-h-0 min-w-0 flex-1 flex-row overflow-hidden">
<div className="relative flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden">
<div
@@ -95,7 +95,12 @@ export function ReadmePanel({
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}
/>
<div className="ml-auto flex shrink-0 items-center">
<RepoSyncActionButton controls={sourceControls} />
@@ -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}
/>
<div className="ml-auto flex shrink-0 items-center">
<RepoSyncActionButton controls={sourceControls} />
@@ -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}
/>
</>
) : (
@@ -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({
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
{onCreateBranch || onDeleteBranch ? (
<>
<DropdownMenuSeparator />
{onCreateBranch ? (
<DropdownMenuItem
data-testid="project-create-branch"
disabled={createBranchDisabled}
onSelect={onCreateBranch}
>
<Plus className="h-4 w-4" />
Create branch…
</DropdownMenuItem>
) : null}
{onDeleteBranch ? (
<DropdownMenuItem
className="text-destructive focus:text-destructive"
data-testid="project-delete-branch"
disabled={deleteBranchDisabled}
onSelect={onDeleteBranch}
title={deleteBranchTitle}
>
<Trash2 className="h-4 w-4" />
Delete {branch}
</DropdownMenuItem>
) : null}
</>
) : null}
</DropdownMenuContent>
</DropdownMenu>
);
@@ -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;
+39
View File
@@ -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<ProjectRepoBranchResult> {
return invokeTauri<RawProjectRepoBranchResult>(
"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<ProjectRepoBranchResult> {
return invokeTauri<RawProjectRepoBranchResult>(
"delete_project_remote_branch",
{
cloneUrl: input.cloneUrl,
branch: input.branch,
expectedCommit: input.expectedCommit,
},
);
}
type RawProjectRepoMergeResult = {
message: string;
merge_commit: string;
@@ -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;
+1
View File
@@ -190,6 +190,7 @@ export type UserStatusLookup = Record<string, UserStatus | null>;
export type {
ProjectLocalRepository,
ProjectLocalRepoSnapshot,
ProjectRepoBranchResult,
ProjectRepoCommit,
ProjectRepoContributor,
ProjectRepoCloneResult,
+77
View File
@@ -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<number>([
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: {
@@ -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(() => {