mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
feat(desktop): manage project branches (#2212)
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -190,6 +190,7 @@ export type UserStatusLookup = Record<string, UserStatus | null>;
|
||||
export type {
|
||||
ProjectLocalRepository,
|
||||
ProjectLocalRepoSnapshot,
|
||||
ProjectRepoBranchResult,
|
||||
ProjectRepoCommit,
|
||||
ProjectRepoContributor,
|
||||
ProjectRepoCloneResult,
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
Reference in New Issue
Block a user