mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
fix(git): make project branch workflows reliable (#2213)
This commit is contained in:
@@ -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));
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
]);
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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. */
|
||||
|
||||
Reference in New Issue
Block a user