fix(git): make project branch workflows reliable (#2213)

This commit is contained in:
thomaspblock
2026-07-21 12:58:39 +02:00
committed by GitHub
parent b2c38c775b
commit 166f27be4b
22 changed files with 604 additions and 124 deletions
+50 -10
View File
@@ -352,6 +352,34 @@ async fn snapshot_workspace_state(
Ok((refs, head))
}
fn resolve_published_head(
refs: &BTreeMap<String, String>,
observed_head: String,
parent_head: &str,
) -> String {
let is_published_branch =
|name: &str| name.starts_with("refs/heads/") && refs.contains_key(name);
if is_published_branch(&observed_head) {
return observed_head;
}
if is_published_branch(parent_head) {
return parent_head.to_string();
}
for preferred in ["refs/heads/main", "refs/heads/master"] {
if refs.contains_key(preferred) {
return preferred.to_string();
}
}
if let Some(branch) = refs.keys().find(|name| name.starts_with("refs/heads/")) {
return branch.clone();
}
if !observed_head.is_empty() {
observed_head
} else {
parent_head.to_string()
}
}
fn digest_from_pack_key(key: &str) -> Result<String, CasError> {
key.strip_prefix("packs/")
.filter(|digest| digest.len() == 64 && digest.chars().all(|c| c.is_ascii_hexdigit()))
@@ -1014,16 +1042,10 @@ async fn cas_publish_inner(
// below uses them as the "negative" set to produce the delta pack.
let (refs_after, head_observed) = snapshot_workspace_state(repo_path, scratch_dir).await?;
// HEAD fallback: a bare repo serving pushes shouldn't have detached
// HEAD, but if `git symbolic-ref` failed (or returned empty), inherit
// the parent's HEAD rather than installing an empty one. `validate()`
// below rejects "empty after fallback" — that's the first-push +
// detached-HEAD case where the writer must declare a HEAD.
let head = if head_observed.is_empty() {
parent_state.parent.head.clone()
} else {
head_observed
};
// Fresh bare repositories can inherit Git's environment-dependent
// `master` symref even when the first push creates `main`. Publish a real
// branch whenever one exists so HEAD never advertises a phantom branch.
let head = resolve_published_head(&refs_after, head_observed, &parent_state.parent.head);
let packs_before = parent_state.parent.packs.len();
let mut compaction_failure = None;
@@ -1330,6 +1352,24 @@ mod tests {
assert!(digest_from_pack_key(&format!("packs/{}", "g".repeat(64))).is_err());
}
#[test]
fn published_head_ignores_dangling_master_when_main_exists() {
let refs = BTreeMap::from([("refs/heads/main".to_string(), "1".repeat(40))]);
assert_eq!(
resolve_published_head(&refs, "refs/heads/master".to_string(), ""),
"refs/heads/main"
);
}
#[test]
fn published_head_preserves_an_existing_nonstandard_branch() {
let refs = BTreeMap::from([("refs/heads/release".to_string(), "1".repeat(40))]);
assert_eq!(
resolve_published_head(&refs, "refs/heads/release".to_string(), "refs/heads/main"),
"refs/heads/release"
);
}
#[test]
fn digest_from_key_strips_prefix() {
let k = format!("manifests/{}", "a".repeat(64));
+26 -6
View File
@@ -178,6 +178,11 @@ pub async fn load_manifest_for_read(
.map(|(_etag, _digest, manifest)| manifest))
}
async fn init_bare_repo(path: &Path) -> Result<(), HydrateError> {
run_git(path, &["init", "--bare", "--quiet"]).await?;
run_git(path, &["symbolic-ref", "HEAD", "refs/heads/main"]).await
}
/// Hydrate a bare repo for write (`receive-pack`) and return the
/// `ParentState` the workspace was hydrated from.
///
@@ -190,10 +195,10 @@ pub async fn load_manifest_for_read(
/// workspace was hydrated from.
///
/// First-push case (pointer absent): returns `(empty bare repo,
/// ParentState::fresh())`. The empty bare repo is a fresh `git init --bare`
/// with no refs and no objects; `receive-pack` will accept the first push
/// and create whatever refs the client sends, and `cas_publish` will CAS
/// the pointer with `If-None-Match: *`.
/// ParentState::fresh())`. The empty bare repo has HEAD initialized to `main`
/// but no refs or objects; `receive-pack` will accept the first push and create
/// whatever refs the client sends, and `cas_publish` will CAS the pointer with
/// `If-None-Match: *`.
///
/// Any below-pointer failure (manifest 404 under non-empty pointer, digest
/// mismatch, malformed pointer body) is a hard error — never silently
@@ -220,7 +225,7 @@ pub async fn hydrate_for_write(
HydrateError::Hydrate(format!("tempdir in {:?}: {e}", options.scratch_dir))
})?;
let path = tempdir.path().to_path_buf();
run_git(&path, &["init", "--bare", "--quiet"]).await?;
init_bare_repo(&path).await?;
Ok((
HydratedRepo {
_tempdir: tempdir,
@@ -294,7 +299,7 @@ async fn materialize_manifest(
let tempdir = TempDir::new_in(options.scratch_dir)
.map_err(|e| HydrateError::Hydrate(format!("tempdir in {:?}: {e}", options.scratch_dir)))?;
let path = tempdir.path().to_path_buf();
run_git(&path, &["init", "--bare", "--quiet"]).await?;
init_bare_repo(&path).await?;
// Phase 1: fetch, write, and index one pack at a time. Keeping only one
// verified pack body resident avoids a manifest with many historical packs
@@ -500,6 +505,21 @@ mod tests {
assert!(!is_hex_oid(""));
}
#[tokio::test]
async fn fresh_bare_repo_defaults_head_to_main() {
let scratch = TempDir::new().expect("scratch");
init_bare_repo(scratch.path())
.await
.expect("initialize bare repo");
assert_eq!(
tokio::fs::read_to_string(scratch.path().join("HEAD"))
.await
.expect("read HEAD")
.trim(),
"ref: refs/heads/main"
);
}
#[tokio::test]
async fn generated_idx_size_is_bounded() {
let scratch = TempDir::new().expect("scratch");
+33 -13
View File
@@ -5,10 +5,11 @@
//!
//! 1. Validates HMAC signature + 30s TTL (fail-closed)
//! 2. Resolves kind:30617 → protection rules
//! 3. Resolves pusher's channel role via buzz-channel binding
//! 4. Promotes Bot → Member (bots in a channel push as members)
//! 5. Calls `buzz_core::git_perms::evaluate_push()`
//! 6. Returns 200 (allow) or 403 (deny with reasons)
//! 3. Grants owner authority to the repo key or its verified managed-agent owner
//! 4. Otherwise resolves the pusher's channel role via buzz-channel binding
//! 5. Promotes Bot → Member (bots in a channel push as members)
//! 6. Calls `buzz_core::git_perms::evaluate_push()`
//! 7. Returns 200 (allow) or 403 (deny with reasons)
//!
//! # Bot Role Model
//!
@@ -252,7 +253,7 @@ pub async fn hook_policy_check(
};
let query = EventQuery {
kinds: Some(vec![30617]),
pubkey: Some(owner_bytes),
pubkey: Some(owner_bytes.clone()),
d_tag: Some(req.repo_id.clone()),
global_only: true,
limit: Some(1),
@@ -316,9 +317,34 @@ pub async fn hook_policy_check(
}
}
// 7. Resolve pusher's role.
// 7. Resolve pusher's role. A cryptographically verified managed-agent
// owner has the same repository authority as the agent key itself.
let repo_owner_hex = hex::encode(repo_event.event.pubkey.to_bytes());
let role = if req.pusher_pubkey == repo_owner_hex {
let pusher_bytes = match hex::decode(&req.pusher_pubkey) {
Ok(bytes) if bytes.len() == 32 => bytes,
_ => return (StatusCode::FORBIDDEN, "invalid pusher pubkey").into_response(),
};
let is_repo_owner = req.pusher_pubkey == repo_owner_hex;
let is_managed_agent_owner = if is_repo_owner {
false
} else {
match state
.db
.is_agent_owner(community, &owner_bytes, &pusher_bytes)
.await
{
Ok(is_owner) => is_owner,
Err(error) => {
error!(
repo = %req.repo_id,
error = %error,
"hook callback: managed-agent owner lookup failed"
);
return (StatusCode::FORBIDDEN, "internal error").into_response();
}
}
};
let role = if is_repo_owner || is_managed_agent_owner {
MemberRole::Owner
} else {
match channel_id {
@@ -327,12 +353,6 @@ pub async fn hook_policy_check(
return (StatusCode::FORBIDDEN, "no channel binding").into_response();
}
Some(ch_id) => {
let pusher_bytes = match hex::decode(&req.pusher_pubkey) {
Ok(b) if b.len() == 32 => b,
_ => {
return (StatusCode::FORBIDDEN, "invalid pusher pubkey").into_response();
}
};
match state
.db
.get_member_role(community, ch_id, &pusher_bytes)
+1
View File
@@ -38,6 +38,7 @@ mod project_git;
mod project_git_branches;
mod project_git_diff;
mod project_git_exec;
mod project_git_push;
mod project_git_workflow;
mod project_repo_paths;
mod project_terminal;
+39 -38
View File
@@ -1,6 +1,7 @@
use super::project_git_exec::{
build_git_auth_config, clean_branch, run_git, validate_workspace_clone_url, GitAuthConfig,
};
use super::project_git_push::push_project_local_repository_blocking;
use super::project_repo_paths::{canonical_repos_roots, find_local_repo_dir};
use crate::app_state::AppState;
use serde::Serialize;
@@ -52,6 +53,7 @@ pub struct ProjectLocalRepoInfo {
pub struct ProjectRepoSyncStatusInfo {
pub local_path: Option<String>,
pub local_branch: Option<String>,
pub local_branches: Vec<String>,
pub local_head: Option<String>,
pub local_short_head: Option<String>,
pub remote_branch: Option<String>,
@@ -492,7 +494,7 @@ pub(crate) fn normalize_branch_option(branch: Option<&str>) -> Option<String> {
clean_branch(branch.map(str::to_string))
}
fn compare_local_remote_status(
pub(crate) fn compare_local_remote_status(
repo_dir: &std::path::Path,
clone_url: &str,
branch_name: Option<&str>,
@@ -502,6 +504,23 @@ fn compare_local_remote_status(
let local_branch = run_git(&["branch", "--show-current"], Some(repo_dir), auth)
.ok()
.and_then(|output| first_output_line(&output));
let local_branches = run_git(
&[
"for-each-ref",
"--count=200",
"--format=%(refname:short)",
"refs/heads/",
],
Some(repo_dir),
auth,
)
.map(|output| {
output
.lines()
.filter_map(|branch| normalize_branch_option(Some(branch)))
.collect()
})
.unwrap_or_default();
// The local checkout's branch name is attacker-influencable (a hostile
// remote can point HEAD at a flag-shaped refname), so it must pass the
// same `clean_branch` validation as relay-supplied names before it is
@@ -549,6 +568,17 @@ fn compare_local_remote_status(
)
.ok()
.and_then(|output| first_output_line(&output));
// A legacy empty clone may have an unborn local `master` while the project
// declares `main`. Permit that mismatch only when the remote has no branch
// refs at all; any lookup failure is treated as non-empty (fail closed).
let remote_has_branches = run_git(
&["ls-remote", "--heads", "--end-of-options", "origin"],
Some(repo_dir),
auth,
)
.map(|output| !output.trim().is_empty())
.unwrap_or(true);
let is_first_publish = remote_head.is_none() && !remote_has_branches;
let merge_base = base_branch.as_deref().and_then(|base_branch| {
run_git(
&[
@@ -596,7 +626,7 @@ fn compare_local_remote_status(
let push_block_reason = if local_head.is_none() {
Some("No local commits to push.".to_string())
} else if local_branch.as_deref() != Some(branch.as_str()) {
} else if local_branch.as_deref() != Some(branch.as_str()) && !is_first_publish {
Some(format!(
"Local checkout is on a different branch than {branch}."
))
@@ -634,6 +664,7 @@ fn compare_local_remote_status(
ProjectRepoSyncStatusInfo {
local_path: Some(repo_dir.display().to_string()),
local_branch,
local_branches,
local_head: local_head.clone(),
local_short_head: local_head.as_deref().map(short_hash),
remote_branch: Some(branch),
@@ -832,6 +863,7 @@ pub async fn get_project_repo_sync_status(
return Ok(ProjectRepoSyncStatusInfo {
local_path: None,
local_branch: None,
local_branches: Vec::new(),
local_head: None,
local_short_head: None,
remote_branch: branch_name
@@ -881,44 +913,13 @@ pub async fn push_project_local_repository(
else {
return Err("No local checkout found.".to_string());
};
let status = compare_local_remote_status(
push_project_local_repository_blocking(
&repo_dir,
&clone_url,
branch_name.as_deref(),
base_branch.as_deref(),
clone_url,
branch_name,
base_branch,
&auth,
);
if !status.can_push {
return Err(status
.push_block_reason
.unwrap_or_else(|| "Local checkout cannot be pushed.".to_string()));
}
let branch = status
.remote_branch
.clone()
.ok_or_else(|| "No branch selected for push.".to_string())?;
let commit = status
.local_head
.clone()
.ok_or_else(|| "No local commit selected for push.".to_string())?;
run_git(
&[
"push",
"--end-of-options",
"origin",
format!("HEAD:{branch}").as_str(),
],
Some(&repo_dir),
&auth,
)?;
Ok(ProjectRepoPushResult {
pushed: true,
message: format!("Pushed {branch} to remote."),
branch,
commit,
merge_base: status.merge_base,
})
)
})
.await
.map_err(|error| format!("repo push task failed: {error}"))?
@@ -0,0 +1,136 @@
use super::project_git::{compare_local_remote_status, ProjectRepoPushResult};
use super::project_git_exec::{run_git, GitAuthConfig};
pub(crate) fn push_project_local_repository_blocking(
repo_dir: &std::path::Path,
clone_url: String,
branch_name: Option<String>,
base_branch: Option<String>,
auth: &GitAuthConfig,
) -> Result<ProjectRepoPushResult, String> {
let status = compare_local_remote_status(
repo_dir,
&clone_url,
branch_name.as_deref(),
base_branch.as_deref(),
auth,
);
if !status.can_push {
return Err(status
.push_block_reason
.unwrap_or_else(|| "Local checkout cannot be pushed.".to_string()));
}
let branch = status
.remote_branch
.clone()
.ok_or_else(|| "No branch selected for push.".to_string())?;
let commit = status
.local_head
.clone()
.ok_or_else(|| "No local commit selected for push.".to_string())?;
if status.local_branch.as_deref() != Some(branch.as_str()) && status.remote_head.is_none() {
run_git(
&["branch", "-M", "--", branch.as_str()],
Some(repo_dir),
auth,
)?;
}
run_git(
&[
"push",
"--end-of-options",
"origin",
format!("HEAD:{branch}").as_str(),
],
Some(repo_dir),
auth,
)?;
Ok(ProjectRepoPushResult {
pushed: true,
message: format!("Pushed {branch} to remote."),
branch,
commit,
merge_base: status.merge_base,
})
}
#[cfg(test)]
mod tests {
use super::push_project_local_repository_blocking;
use crate::commands::project_git::compare_local_remote_status;
use crate::commands::project_git_exec::{build_test_git_auth_config, run_git};
#[test]
fn first_push_aligns_legacy_master_checkout_to_main() {
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 checkout = root.path().join("checkout");
let remote_path = remote.to_str().expect("remote path");
let checkout_path = checkout.to_str().expect("checkout path");
run_git(&["init", "--bare", "--", remote_path], None, &auth).expect("initialize remote");
run_git(&["init", "--", checkout_path], None, &auth).expect("initialize checkout");
run_git(
&["symbolic-ref", "HEAD", "refs/heads/master"],
Some(&checkout),
&auth,
)
.expect("set legacy branch");
std::fs::write(checkout.join("README.md"), "first commit\n").expect("write fixture");
run_git(&["add", "README.md"], Some(&checkout), &auth).expect("stage fixture");
run_git(
&[
"-c",
"user.name=Buzz Test",
"-c",
"user.email=test@example.com",
"commit",
"-m",
"Initial commit",
],
Some(&checkout),
&auth,
)
.expect("commit fixture");
run_git(&["branch", "space"], Some(&checkout), &auth).expect("create second local branch");
run_git(
&["remote", "add", "origin", remote_path],
Some(&checkout),
&auth,
)
.expect("add remote");
let status = compare_local_remote_status(&checkout, remote_path, Some("main"), None, &auth);
assert_eq!(status.local_branches, ["master", "space"]);
let result = push_project_local_repository_blocking(
&checkout,
remote_path.to_string(),
Some("main".to_string()),
None,
&auth,
)
.expect("publish first commit");
assert_eq!(result.branch, "main");
assert_eq!(
run_git(&["branch", "--show-current"], Some(&checkout), &auth)
.expect("read local branch")
.trim(),
"main"
);
assert!(run_git(
&[
format!("--git-dir={remote_path}").as_str(),
"show-ref",
"--verify",
"refs/heads/main",
],
None,
&auth,
)
.is_ok());
}
}
@@ -333,6 +333,26 @@ fn clone_destination_root(repos_dir: Option<&str>) -> Result<std::path::PathBuf,
}
}
fn align_unborn_head_branch(
repo_dir: &std::path::Path,
branch: Option<&str>,
auth: &GitAuthConfig,
) -> Result<(), String> {
let Some(branch) = branch else {
return Ok(());
};
if run_git(&["rev-parse", "--verify", "HEAD"], Some(repo_dir), auth).is_ok() {
return Ok(());
}
let target = format!("refs/heads/{branch}");
run_git(
&["symbolic-ref", "HEAD", target.as_str()],
Some(repo_dir),
auth,
)
.map(|_| ())
}
pub(crate) fn clone_project_repository_blocking(
repos_dir: Option<&str>,
project_dtag: &str,
@@ -381,6 +401,7 @@ pub(crate) fn clone_project_repository_blocking(
auth,
)?;
}
align_unborn_head_branch(&repo_dir, branch.as_deref(), auth)?;
Ok(ProjectRepoCloneResult {
path: repo_dir.display().to_string(),
@@ -642,12 +663,29 @@ pub async fn merge_project_pull_request(
#[cfg(test)]
mod tests {
use super::{
build_merged_status_event, build_review_request_event, classify_merge_error,
normalize_commit, same_repository, validate_merge_status_metadata,
align_unborn_head_branch, build_merged_status_event, build_review_request_event,
classify_merge_error, normalize_commit, same_repository, validate_merge_status_metadata,
ProjectPullRequestMergeError,
};
use crate::commands::project_git_exec::{build_test_git_auth_config, run_git};
use nostr::{Event, JsonUtil, Keys, Timestamp};
#[test]
fn empty_clone_uses_requested_default_branch() {
let auth = build_test_git_auth_config().expect("build test git config");
let repo = tempfile::tempdir().expect("create repository");
run_git(&["init"], Some(repo.path()), &auth).expect("initialize repository");
align_unborn_head_branch(repo.path(), Some("main"), &auth).expect("align unborn HEAD");
assert_eq!(
std::fs::read_to_string(repo.path().join(".git/HEAD"))
.expect("read HEAD")
.trim(),
"ref: refs/heads/main"
);
}
#[test]
fn normalize_commit_accepts_sha1_and_sha256_hex() {
assert_eq!(normalize_commit(&"A".repeat(40)), Some("a".repeat(40)));
@@ -61,6 +61,8 @@ export function useProjectBranchActions(input: {
defaultBranch: string | null;
deleteBranchReason: string | null;
refetchRepoState: () => Promise<unknown>;
rememberBranch: (branch: { name: string; commit: string }) => void;
forgetBranch: (branch: string) => void;
selectBranch: (branch: string | null) => void;
}) {
const [createOpen, setCreateOpen] = React.useState(false);
@@ -81,6 +83,7 @@ export function useProjectBranchActions(input: {
newBranch,
});
await input.refetchRepoState();
input.rememberBranch({ name: result.branch, commit: result.commit });
input.selectBranch(result.branch);
toast.success(result.message);
},
@@ -89,6 +92,7 @@ export function useProjectBranchActions(input: {
input.activeBranch,
input.activeBranchCommit,
input.refetchRepoState,
input.rememberBranch,
input.selectBranch,
],
);
@@ -104,6 +108,7 @@ export function useProjectBranchActions(input: {
branch: input.activeBranch,
expectedCommit: input.activeRemoteBranch.commit,
});
input.forgetBranch(result.branch);
input.selectBranch(input.defaultBranch);
await input.refetchRepoState();
toast.success(result.message);
@@ -113,6 +118,7 @@ export function useProjectBranchActions(input: {
input.activeRemoteBranch,
input.defaultBranch,
input.deleteBranchReason,
input.forgetBranch,
input.refetchRepoState,
input.selectBranch,
]);
+8 -3
View File
@@ -38,6 +38,7 @@ import type {
RelayEvent,
} from "@/shared/api/types";
import { summarizeProjectActivityEvents } from "./projectActivity.mjs";
import { resolveProjectDefaultBranch } from "./lib/projectBranches";
import { effectiveCloneUrls } from "./lib/projectCloneUrl";
import type { ProjectIssue } from "./projectIssues.mjs";
import { projectIssueEventsToIssues } from "./projectIssues.mjs";
@@ -308,9 +309,13 @@ async function fetchProject(projectId: string): Promise<Project | null> {
if (isDeletedByA(project, deletionEvents)) return null;
const repoState = await fetchRepoState(project);
return repoState?.head
? { ...project, defaultBranch: repoState.head }
: project;
return {
...project,
defaultBranch: resolveProjectDefaultBranch(
project.defaultBranch,
repoState,
),
};
}
function eventToRepoState(event: RelayEvent): RepoState {
@@ -6,6 +6,7 @@ import {
projectBranchManagementState,
projectBranchNameError,
projectBranchOptions,
resolveProjectDefaultBranch,
} from "./projectBranches.ts";
test("normalizes plain and full branch refs", () => {
@@ -37,10 +38,37 @@ test("reports duplicate branch names", () => {
test("combines remote and local branch options without duplicates", () => {
assert.deepEqual(
projectBranchOptions(["main", "feature/remote"], "feature/local"),
["main", "feature/remote", "feature/local"],
projectBranchOptions(
["main", "feature/remote"],
["feature/local", "space"],
),
["main", "feature/remote", "feature/local", "space"],
);
assert.deepEqual(projectBranchOptions(["main"], ["main"]), ["main"]);
});
test("ignores a dangling HEAD and selects a published branch", () => {
assert.equal(
resolveProjectDefaultBranch("master", {
branches: [{ name: "main" }],
head: "master",
}),
"main",
);
assert.equal(
resolveProjectDefaultBranch("release", {
branches: [{ name: "release" }, { name: "main" }],
head: "missing",
}),
"release",
);
});
test("preserves HEAD for empty repositories", () => {
assert.equal(
resolveProjectDefaultBranch("main", { branches: [], head: "master" }),
"master",
);
assert.deepEqual(projectBranchOptions(["main"], "main"), ["main"]);
});
test("derives branch commits and deletion safeguards", () => {
@@ -38,15 +38,44 @@ export function projectBranchNameError(
export function projectBranchOptions(
remoteBranches: string[],
localBranch?: string | null,
localBranches: string[] = [],
): string[] {
return [
...new Set(
[...remoteBranches, localBranch].filter((branch): branch is string =>
Boolean(branch),
),
),
];
return [...new Set([...remoteBranches, ...localBranches].filter(Boolean))];
}
export function projectBranchOptionsFromSync(
remoteBranches: string[],
syncStatus?: {
localBranch: string | null;
localBranches: string[];
localHead: string | null;
},
): string[] {
const localBranches =
syncStatus?.localBranches ??
(syncStatus?.localHead && syncStatus.localBranch
? [syncStatus.localBranch]
: []);
return projectBranchOptions(remoteBranches, localBranches);
}
/** Resolve a usable default branch when a repository advertises a stale HEAD. */
export function resolveProjectDefaultBranch(
announcedBranch: string,
repoState?: {
branches: Array<{ name: string }>;
head: string | null;
} | null,
): string {
if (!repoState || repoState.branches.length === 0) {
return repoState?.head ?? announcedBranch;
}
const published = new Set(repoState.branches.map((branch) => branch.name));
if (repoState.head && published.has(repoState.head)) return repoState.head;
if (published.has(announcedBranch)) return announcedBranch;
if (published.has("main")) return "main";
if (published.has("master")) return "master";
return repoState.branches[0]?.name ?? announcedBranch;
}
export function projectBranchManagementState(input: {
@@ -26,7 +26,11 @@ import {
import { Input } from "@/shared/ui/input";
function errorMessage(error: unknown, fallback: string) {
return error instanceof Error ? error.message : fallback;
if (!(error instanceof Error)) return fallback;
if (error.message.includes("no channel binding")) {
return "This repository is owned by another identity and is not linked to a project channel.";
}
return error.message;
}
export function CreateProjectBranchDialog({
@@ -29,6 +29,7 @@ import {
usePushProjectLocalRepositoryMutation,
} from "@/features/projects/repoSyncHooks";
import { useProjectBranchActions } from "@/features/projects/branchMutations";
import { useOptimisticProjectBranches } from "@/features/projects/useOptimisticProjectBranches";
import { useUpdateProjectPullRequestMutation } from "@/features/projects/pullRequestMutations";
import { useCreateProjectIssueMutation } from "@/features/projects/issueMutations";
import { useProfileQuery, useUsersBatchQuery } from "@/features/profile/hooks";
@@ -63,7 +64,8 @@ import { useGitIdentityQuery } from "@/features/projects/useGitIdentity";
import type { ViewerGitIdentity } from "@/features/projects/lib/projectContributorMatching";
import {
projectBranchManagementState,
projectBranchOptions,
projectBranchOptionsFromSync,
resolveProjectDefaultBranch,
} from "@/features/projects/lib/projectBranches";
import { normalizeRepositoryUrl } from "@/features/projects/lib/projectsViewHelpers";
import { WorkspaceTabs } from "./ProjectWorkspaceTabs";
@@ -111,25 +113,24 @@ export function ProjectDetailScreen(props: ProjectDetailScreenProps) {
const project = projectQuery.data;
const repoStateQuery = useRepoStateQuery(project);
const pullRequestsQuery = useProjectPullRequestsQuery(project);
const branchOptions = React.useMemo(() => {
const names = [
project?.defaultBranch,
...(repoStateQuery.data?.branches.map((branch) => branch.name) ?? []),
...(pullRequestsQuery.data
?.map((pullRequest) => pullRequest.branchName)
.filter((name): name is string => Boolean(name)) ?? []),
].filter((name): name is string => Boolean(name));
return [...new Set(names)];
}, [
project?.defaultBranch,
pullRequestsQuery.data,
repoStateQuery.data?.branches,
]);
const defaultBranch = project
? resolveProjectDefaultBranch(project.defaultBranch, repoStateQuery.data)
: null;
const { branchOptions, forgetBranch, managedBranches, rememberBranch } =
useOptimisticProjectBranches({
defaultBranch,
observedBranches: repoStateQuery.data?.branches ?? [],
projectId,
referencedBranches:
pullRequestsQuery.data?.map(
(pullRequest) => pullRequest.branchName ?? null,
) ?? [],
});
const [selectedBranch, setSelectedBranch] = React.useState<string | null>(
null,
);
const activeBranch =
selectedBranch ?? project?.defaultBranch ?? branchOptions[0] ?? null;
selectedBranch ?? defaultBranch ?? branchOptions[0] ?? null;
const [selectedPullRequestId, setSelectedPullRequestId] = React.useState<
string | null
>(pullRequestId ?? null);
@@ -269,20 +270,14 @@ export function ProjectDetailScreen(props: ProjectDetailScreenProps) {
repoSource === "local"
? localRepoDiffQuery.isLoading
: repoDiffQuery.isLoading;
const branchOptionsWithLocal = React.useMemo(
() =>
projectBranchOptions(
branchOptions,
repoSyncStatusQuery.data?.localBranch,
),
[branchOptions, repoSyncStatusQuery.data?.localBranch],
const branchOptionsWithLocal = projectBranchOptionsFromSync(
branchOptions,
repoSyncStatusQuery.data,
);
const defaultBranch =
repoStateQuery.data?.head ?? project?.defaultBranch ?? null;
const { activeBranchCommit, activeRemoteBranch, deleteBranchReason } =
projectBranchManagementState({
activeBranch,
branches: repoStateQuery.data?.branches ?? [],
branches: managedBranches,
defaultBranch,
hasOpenPullRequest: (pullRequestsQuery.data ?? []).some(
(pullRequest) =>
@@ -293,16 +288,38 @@ export function ProjectDetailScreen(props: ProjectDetailScreenProps) {
remoteHead: repoSyncStatusQuery.data?.remoteHead,
snapshotCommit: repoSnapshotQuery.data?.latestCommit?.hash,
});
const handleBranchChange = React.useCallback(
(branch: string | null) => {
setSelectedBranch(branch);
if (
branch &&
repoSource === "local" &&
branch !== repoSyncStatusQuery.data?.localBranch
) {
setRepoSource("remote");
}
},
[repoSource, repoSyncStatusQuery.data?.localBranch],
);
const branchActions = useProjectBranchActions({
activeBranch,
activeBranchCommit,
activeRemoteBranch,
defaultBranch,
deleteBranchReason,
forgetBranch,
project,
refetchRepoState: repoStateQuery.refetch,
selectBranch: setSelectedBranch,
rememberBranch,
selectBranch: handleBranchChange,
});
const createBranchReason = !activeBranch
? "Choose a branch first."
: !activeBranchCommit
? repoSyncStatusQuery.data?.localHead
? `Push the first local commit to ${activeBranch} before creating another branch.`
: "Create the repository's first commit before creating another branch."
: null;
const handleFetchRepo = React.useCallback(async () => {
const results = await Promise.all([
repoSnapshotQuery.refetch(),
@@ -324,9 +341,10 @@ export function ProjectDetailScreen(props: ProjectDetailScreenProps) {
const filesSourceControls: RepoSourceHeaderControls = {
branch: activeBranch ?? "",
branchOptions: branchOptionsWithLocal,
onBranchChange: setSelectedBranch,
onBranchChange: handleBranchChange,
onCreateBranch: () => branchActions.setCreateOpen(true),
createBranchDisabled: branchActions.createPending || !activeBranchCommit,
createBranchTitle: createBranchReason ?? "Create a remote branch",
onDeleteBranch: () => branchActions.setDeleteOpen(true),
deleteBranchDisabled:
branchActions.deletePending || Boolean(deleteBranchReason),
@@ -398,9 +416,9 @@ export function ProjectDetailScreen(props: ProjectDetailScreenProps) {
if (currentBranch && branchOptions.includes(currentBranch)) {
return currentBranch;
}
return project.defaultBranch ?? branchOptions[0] ?? null;
return defaultBranch ?? branchOptions[0] ?? null;
});
}, [project, branchOptions, projectPending]);
}, [project, branchOptions, defaultBranch, projectPending]);
React.useEffect(() => {
setRepoSource((currentSource) => {
if (currentSource === "local" && !hasLocalCheckout) return "remote";
@@ -96,6 +96,7 @@ export function ReadmePanel({
branchOptions={sourceControls.branchOptions}
compact
createBranchDisabled={sourceControls.createBranchDisabled}
createBranchTitle={sourceControls.createBranchTitle}
deleteBranchDisabled={sourceControls.deleteBranchDisabled}
deleteBranchTitle={sourceControls.deleteBranchTitle}
onBranchChange={sourceControls.onBranchChange}
@@ -725,6 +725,7 @@ export function RepositoryFilesPanel({
branchOptions={sourceControls.branchOptions}
compact
createBranchDisabled={sourceControls.createBranchDisabled}
createBranchTitle={sourceControls.createBranchTitle}
deleteBranchDisabled={sourceControls.deleteBranchDisabled}
deleteBranchTitle={sourceControls.deleteBranchTitle}
onBranchChange={sourceControls.onBranchChange}
@@ -763,6 +764,7 @@ export function RepositoryFilesPanel({
branchOptions={sourceControls.branchOptions}
compact
createBranchDisabled={sourceControls.createBranchDisabled}
createBranchTitle={sourceControls.createBranchTitle}
deleteBranchDisabled={sourceControls.deleteBranchDisabled}
deleteBranchTitle={sourceControls.deleteBranchTitle}
onBranchChange={sourceControls.onBranchChange}
@@ -29,6 +29,7 @@ export function RepositoryBranchDropdown({
branchOptions,
compact,
createBranchDisabled,
createBranchTitle,
deleteBranchDisabled,
deleteBranchTitle,
onBranchChange,
@@ -40,6 +41,7 @@ export function RepositoryBranchDropdown({
/** Smaller trigger for inline headers. */
compact?: boolean;
createBranchDisabled?: boolean;
createBranchTitle?: string;
deleteBranchDisabled?: boolean;
deleteBranchTitle?: string;
onBranchChange: (branch: string) => void;
@@ -85,14 +87,22 @@ export function RepositoryBranchDropdown({
<>
<DropdownMenuSeparator />
{onCreateBranch ? (
<DropdownMenuItem
data-testid="project-create-branch"
disabled={createBranchDisabled}
onSelect={onCreateBranch}
>
<Plus className="h-4 w-4" />
Create branch
</DropdownMenuItem>
<>
<DropdownMenuItem
data-testid="project-create-branch"
disabled={createBranchDisabled}
onSelect={onCreateBranch}
title={createBranchTitle}
>
<Plus className="h-4 w-4" />
Create branch
</DropdownMenuItem>
{createBranchDisabled && createBranchTitle ? (
<p className="max-w-56 px-2 py-1 text-xs text-muted-foreground">
{createBranchTitle}
</p>
) : null}
</>
) : null}
{onDeleteBranch ? (
<DropdownMenuItem
@@ -120,6 +130,7 @@ export type RepoSourceHeaderControls = {
onBranchChange: (branch: string) => void;
onCreateBranch?: () => void;
createBranchDisabled?: boolean;
createBranchTitle?: string;
onDeleteBranch?: () => void;
deleteBranchDisabled?: boolean;
deleteBranchTitle?: string;
@@ -0,0 +1,88 @@
import * as React from "react";
type ProjectBranch = { name: string; commit: string };
export function useOptimisticProjectBranches(input: {
defaultBranch: string | null;
observedBranches: ProjectBranch[];
projectId: string;
referencedBranches: Array<string | null>;
}) {
const [state, setState] = React.useState<{
projectId: string;
branches: ProjectBranch[];
}>({ projectId: input.projectId, branches: [] });
const optimisticBranches =
state.projectId === input.projectId ? state.branches : [];
const branchOptions = React.useMemo(() => {
const names = [
input.defaultBranch,
...input.observedBranches.map((branch) => branch.name),
...optimisticBranches.map((branch) => branch.name),
...input.referencedBranches,
].filter((name): name is string => Boolean(name));
return [...new Set(names)];
}, [
input.defaultBranch,
input.observedBranches,
input.referencedBranches,
optimisticBranches,
]);
const managedBranches = React.useMemo(() => {
const branches = [...input.observedBranches];
for (const branch of optimisticBranches) {
if (!branches.some((candidate) => candidate.name === branch.name)) {
branches.push(branch);
}
}
return branches;
}, [input.observedBranches, optimisticBranches]);
React.useEffect(() => {
const observedNames = new Set(
input.observedBranches.map((branch) => branch.name),
);
if (observedNames.size === 0) return;
setState((current) => {
if (current.projectId !== input.projectId) return current;
const pending = current.branches.filter(
(branch) => !observedNames.has(branch.name),
);
return pending.length === current.branches.length
? current
: { ...current, branches: pending };
});
}, [input.observedBranches, input.projectId]);
const rememberBranch = React.useCallback(
(branch: ProjectBranch) => {
setState((current) => {
const branches =
current.projectId === input.projectId ? current.branches : [];
return branches.some((candidate) => candidate.name === branch.name)
? current
: { projectId: input.projectId, branches: [...branches, branch] };
});
},
[input.projectId],
);
const forgetBranch = React.useCallback(
(branchName: string) => {
setState((current) => {
if (current.projectId !== input.projectId) return current;
return {
...current,
branches: current.branches.filter(
(branch) => branch.name !== branchName,
),
};
});
},
[input.projectId],
);
return { branchOptions, forgetBranch, managedBranches, rememberBranch };
}
+2
View File
@@ -68,6 +68,7 @@ type RawProjectLocalRepository = {
type RawProjectRepoSyncStatus = {
local_path: string | null;
local_branch: string | null;
local_branches: string[];
local_head: string | null;
local_short_head: string | null;
remote_branch: string | null;
@@ -281,6 +282,7 @@ function fromRawProjectRepoSyncStatus(
return {
localPath: status.local_path,
localBranch: status.local_branch,
localBranches: status.local_branches,
localHead: status.local_head,
localShortHead: status.local_short_head,
remoteBranch: status.remote_branch,
@@ -58,6 +58,7 @@ export type ProjectLocalRepository = {
export type ProjectRepoSyncStatus = {
localPath: string | null;
localBranch: string | null;
localBranches: string[];
localHead: string | null;
localShortHead: string | null;
remoteBranch: string | null;
+13 -1
View File
@@ -127,6 +127,8 @@ type MockSearchProfileSeed = {
type E2eConfig = {
mode?: "mock" | "relay";
mock?: {
/** Advertised HEAD for the first mock project without adding that branch. */
projectHeadBranch?: string;
/** Builderlab account returned by hosted-community onboarding. Null/omitted = signed out. */
builderlabAuth?: {
email?: string;
@@ -962,6 +964,7 @@ declare global {
__BUZZ_E2E_PROJECT_REPO_SYNC_STATUS__?: {
local_path: string | null;
local_branch: string | null;
local_branches: string[];
local_head: string | null;
local_short_head: string | null;
remote_branch: string | null;
@@ -4783,7 +4786,14 @@ function buildMockProjectEvents(): RelayEvent[] {
"",
[
["d", seed.dtag],
["HEAD", "ref: refs/heads/main"],
[
"HEAD",
`ref: refs/heads/${
projectIndex === 0
? (getConfig()?.mock?.projectHeadBranch ?? "main")
: "main"
}`,
],
["refs/heads/main", "0123456789abcdef0123456789abcdef01234567"],
],
owner,
@@ -9315,6 +9325,7 @@ export function maybeInstallE2eTauriMocks() {
window.__BUZZ_E2E_PROJECT_REPO_SYNC_STATUS__ ?? {
local_path: null,
local_branch: null,
local_branches: [],
local_head: null,
local_short_head: null,
remote_branch: "main",
@@ -9367,6 +9378,7 @@ export function maybeInstallE2eTauriMocks() {
window.__BUZZ_E2E_PROJECT_REPO_SYNC_STATUS__ = {
local_path: path,
local_branch: "main",
local_branches: ["main"],
local_head: commit,
local_short_head: commit.slice(0, 7),
remote_branch: "main",
+16 -1
View File
@@ -712,7 +712,7 @@ test("project branches can be created from the selected remote branch", async ({
page,
}) => {
await enableProjectsFeature(page);
await installMockBridge(page);
await installMockBridge(page, { projectHeadBranch: "master" });
await openBuzzProject(page);
await page.getByRole("button", { name: /main/ }).click();
@@ -730,6 +730,17 @@ test("project branches can be created from the selected remote branch", async ({
await expect(
page.getByRole("button", { name: /feature\/branch-management/ }),
).toBeVisible();
await page
.getByRole("button", { name: /feature\/branch-management/ })
.click();
await expect(
page.getByRole("menuitemradio", { name: "feature/branch-management" }),
).toBeVisible();
await page.getByRole("menuitemradio", { name: "main" }).click();
await page.getByRole("button", { name: /main/ }).click();
await expect(
page.getByRole("menuitemradio", { name: "feature/branch-management" }),
).toBeVisible();
const commands = await page.evaluate(
() => window.__BUZZ_E2E_COMMANDS__ ?? [],
);
@@ -775,6 +786,7 @@ test("pushed local branch can open a pull request", async ({ page }) => {
window.__BUZZ_E2E_PROJECT_REPO_SYNC_STATUS__ = {
local_path: "/tmp/buzz/REPOS/buzz",
local_branch: "feature/projects-workflow",
local_branches: ["feature/projects-workflow", "space"],
local_head: commit,
local_short_head: commit.slice(0, 7),
remote_branch: "feature/projects-workflow",
@@ -796,6 +808,9 @@ test("pushed local branch can open a pull request", async ({ page }) => {
await openBuzzProject(page);
await page.getByRole("button", { name: /main/ }).click();
await expect(
page.getByRole("menuitemradio", { name: "space" }),
).toBeVisible();
await page
.getByRole("menuitemradio", { name: "feature/projects-workflow" })
.click();
+2
View File
@@ -127,6 +127,8 @@ export type MockAgentMemoryListing = {
};
type MockBridgeOptions = {
/** Advertised HEAD for the first mock project without adding that branch. */
projectHeadBranch?: string;
/** Builderlab account returned by hosted-community onboarding. Null/omitted = signed out. */
builderlabAuth?: { email?: string; name?: string; expiresAt: string } | null;
/** Bound Builderlab Nostr identity. Null/omitted = not linked yet. */