///| PR Merge operations
///|
pub(all) enum PrMergeStrategy {
Merge // Create merge commit
Squash // Squash all commits
FastForward // Fast-forward if possible
}
///|
pub struct PrMergeResult {
success : Bool
commit_id : @bit.ObjectId?
conflicts : Array[String]
message : String
}
///|
pub struct PrMergePolicy {
required_approvals : Int
allow_request_changes : Bool
require_signed_records : Bool
required_workflows : Array[String]
}
///|
pub fn PrMergePolicy::new(
required_approvals : Int,
allow_request_changes : Bool,
require_signed_records : Bool,
required_workflows? : Array[String] = [],
) -> PrMergePolicy {
{
required_approvals,
allow_request_changes,
require_signed_records,
required_workflows,
}
}
///|
pub fn PrMergePolicy::default() -> PrMergePolicy {
PrMergePolicy::new(0, true, false)
}
///|
pub fn PrMergePolicy::required_approvals(self : PrMergePolicy) -> Int {
self.required_approvals
}
///|
pub fn PrMergePolicy::allow_request_changes(self : PrMergePolicy) -> Bool {
self.allow_request_changes
}
///|
pub fn PrMergePolicy::require_signed_records(self : PrMergePolicy) -> Bool {
self.require_signed_records
}
///|
pub fn PrMergePolicy::required_workflows(self : PrMergePolicy) -> Array[String] {
self.required_workflows
}
///|
pub fn PrMergeResult::success(self : PrMergeResult) -> Bool {
self.success
}
///|
pub fn PrMergeResult::commit_id(self : PrMergeResult) -> @bit.ObjectId? {
self.commit_id
}
///|
pub fn PrMergeResult::conflicts(self : PrMergeResult) -> Array[String] {
self.conflicts
}
///|
pub fn PrMergeResult::message(self : PrMergeResult) -> String {
self.message
}
///|
fn summarize_latest_review_state(reviews : Array[PrReview]) -> (Int, Bool) {
let latest_verdicts : Map[String, ReviewVerdict] = Map([])
for review in reviews {
latest_verdicts[review.author] = review.verdict
}
let mut approvals = 0
let mut has_request_changes = false
for item in latest_verdicts.to_array() {
let (_, verdict) = item
match verdict {
ReviewVerdict::Approved => approvals += 1
ReviewVerdict::RequestChanges => has_request_changes = true
ReviewVerdict::Comment => ()
}
}
(approvals, has_request_changes)
}
///|
fn find_pr_workflow_result(
workflows : Array[PrWorkflowResult],
task : String,
) -> PrWorkflowResult? {
let mut found : PrWorkflowResult? = None
for workflow in workflows {
if workflow.task() == task {
found = Some(workflow)
break
}
}
found
}
///|
fn ensure_required_workflows_success(
workflows : Array[PrWorkflowResult],
required_workflows : Array[String],
) -> Unit raise @bit.GitError {
for task in required_workflows {
match find_pr_workflow_result(workflows, task) {
Some(workflow) =>
if workflow.status() != PrWorkflowStatus::Success {
raise @bit.GitError::InvalidObject(
"Merge blocked by policy: required workflow '\{task}' status=\{workflow.status().to_string()}",
)
}
None =>
raise @bit.GitError::InvalidObject(
"Merge blocked by policy: required workflow '\{task}' has no result",
)
}
}
}
///|
pub fn Hub::check_merge_policy(
self : Hub,
objects : &@lib.ObjectStore,
refs : &@lib.RefStore,
pr_id : String,
policy : PrMergePolicy,
signing_key? : String? = None,
) -> Unit raise @bit.GitError {
let pr = self.get_pr(objects, pr_id)
guard pr is Some(existing_pr) else { return () }
if existing_pr.state != PrState::Open {
return ()
}
let reviews = self.list_reviews(objects, pr_id)
let (approved_count, has_request_changes) = summarize_latest_review_state(
reviews,
)
if !policy.allow_request_changes && has_request_changes {
raise @bit.GitError::InvalidObject(
"Merge blocked by policy: request-changes review is present",
)
}
if approved_count < policy.required_approvals {
raise @bit.GitError::InvalidObject(
"Merge blocked by policy: required approvals=\{policy.required_approvals}, current approvals=\{approved_count}",
)
}
let mut workflows = self.list_pr_workflows(objects, pr_id)
if policy.require_signed_records {
guard signing_key is Some(sign_key) else {
raise @bit.GitError::InvalidObject(
"Merge blocked by policy: require_signed_records=true requires signing key (BIT_COLLAB_SIGN_KEY for CLI)",
)
}
let strict_hub = Hub::load(
objects,
refs,
signing_key=Some(sign_key),
require_signed=true,
)
if strict_hub.get_pr(objects, pr_id) is None {
raise @bit.GitError::InvalidObject(
"Merge blocked by policy: PR metadata is unsigned or has invalid signature",
)
}
let strict_reviews = strict_hub.list_reviews(objects, pr_id)
if strict_reviews.length() != reviews.length() {
raise @bit.GitError::InvalidObject(
"Merge blocked by policy: unsigned or invalid PR reviews are present",
)
}
workflows = strict_hub.list_pr_workflows(objects, pr_id)
}
if policy.required_workflows.length() > 0 {
ensure_required_workflows_success(workflows, policy.required_workflows)
}
}
///|
/// Check if a PR can be merged (no conflicts, PR is open)
pub fn Hub::can_merge(
self : Hub,
objects : &@lib.ObjectStore,
refs : &@lib.RefStore,
pr_id : String,
) -> Bool raise @bit.GitError {
let pr = self.get_pr(objects, pr_id)
guard pr is Some(existing) else { return false }
if existing.state != PrState::Open {
return false
}
let source_commit = refs.resolve(existing.source_branch)
let target_commit = refs.resolve(existing.target_branch)
guard source_commit is Some(_) else { return false }
guard target_commit is Some(_) else { return false }
true
}
///|
/// Merge a Pull Request
pub fn Hub::merge_pr(
self : Hub,
objects : &@lib.ObjectStore,
refs : &@lib.RefStore,
clock : &@lib.Clock,
pr_id : String,
merger : String,
strategy? : PrMergeStrategy = PrMergeStrategy::Merge,
) -> PrMergeResult raise @bit.GitError {
let timestamp = clock.now()
// Get and validate PR
let pr = self.get_pr(objects, pr_id)
guard pr is Some(existing) else {
return {
success: false,
commit_id: None,
conflicts: [],
message: "PR not found: \{pr_id}",
}
}
if existing.state != PrState::Open {
return {
success: false,
commit_id: None,
conflicts: [],
message: "PR is not open: \{pr_id}",
}
}
// Resolve source commit from source_ref/source_branch, then fallback to stored source_commit
let source_id = match resolve_pr_source_commit(objects, refs, existing) {
Ok(id) => id
Err(message) =>
return { success: false, commit_id: None, conflicts: [], message }
}
let target_commit = refs.resolve(existing.target_branch)
guard target_commit is Some(target_id) else {
return {
success: false,
commit_id: None,
conflicts: [],
message: "Target branch not found: \{existing.target_branch}",
}
}
// Create merge message
let merge_message = "Merge PR #\{pr_id}: \{existing.title}\n\nMerged-by: \{merger}\n"
// Check for fast-forward possibility
if is_ancestor(objects, target_id, source_id) {
// Fast-forward is possible
match strategy {
FastForward => {
update_branch_ref(refs, existing.target_branch, source_id)
mark_pr_merged(self, objects, refs, clock, pr_id, existing, source_id)
return {
success: true,
commit_id: Some(source_id),
conflicts: [],
message: "Fast-forward merge completed",
}
}
Merge => {
let source_tree = get_commit_tree_id(objects, source_id)
guard source_tree is Some(tree_id) else {
return {
success: false,
commit_id: None,
conflicts: [],
message: "Cannot read source tree",
}
}
let commit = @bit.Commit::new(
tree_id,
[target_id, source_id],
merger,
timestamp,
"+0000",
merger,
timestamp,
"+0000",
merge_message,
)
let commit_bytes = @bit.serialize_commit_content(commit)
let commit_id = objects.put(@bit.ObjectType::Commit, commit_bytes)
update_branch_ref(refs, existing.target_branch, commit_id)
mark_pr_merged(self, objects, refs, clock, pr_id, existing, commit_id)
return {
success: true,
commit_id: Some(commit_id),
conflicts: [],
message: "Merge commit created",
}
}
Squash => {
let source_tree = get_commit_tree_id(objects, source_id)
guard source_tree is Some(tree_id) else {
return {
success: false,
commit_id: None,
conflicts: [],
message: "Cannot read source tree",
}
}
let squash_message = "Squash PR #\{pr_id}: \{existing.title}\n\nSquashed-by: \{merger}\n"
let commit = @bit.Commit::new(
tree_id,
[target_id],
merger,
timestamp,
"+0000",
merger,
timestamp,
"+0000",
squash_message,
)
let commit_bytes = @bit.serialize_commit_content(commit)
let commit_id = objects.put(@bit.ObjectType::Commit, commit_bytes)
update_branch_ref(refs, existing.target_branch, commit_id)
mark_pr_merged(self, objects, refs, clock, pr_id, existing, commit_id)
return {
success: true,
commit_id: Some(commit_id),
conflicts: [],
message: "Squash merge completed",
}
}
}
}
// Check if already up to date
if is_ancestor(objects, source_id, target_id) {
return {
success: false,
commit_id: None,
conflicts: [],
message: "Already up to date",
}
}
// Find merge base
let base = find_merge_base(objects, target_id, source_id)
// Collect files from each tree
let base_files = match base {
None => Map([])
Some(id) => collect_files_from_commit(objects, id)
}
let target_files = collect_files_from_commit(objects, target_id)
let source_files = collect_files_from_commit(objects, source_id)
// Perform three-way merge
let (merged_files, conflicts) = three_way_merge(
base_files, target_files, source_files,
)
if conflicts.length() > 0 {
return {
success: false,
commit_id: None,
conflicts,
message: "Merge conflicts detected",
}
}
// Create merged tree
let tree_id = create_tree_from_files(objects, merged_files)
// Create merge commit
let parents = match strategy {
Squash => [target_id]
_ => [target_id, source_id]
}
let commit = @bit.Commit::new(
tree_id, parents, merger, timestamp, "+0000", merger, timestamp, "+0000", merge_message,
)
let commit_bytes = @bit.serialize_commit_content(commit)
let commit_id = objects.put(@bit.ObjectType::Commit, commit_bytes)
// Update target branch
update_branch_ref(refs, existing.target_branch, commit_id)
// Mark PR as merged
mark_pr_merged(self, objects, refs, clock, pr_id, existing, commit_id)
{
success: true,
commit_id: Some(commit_id),
conflicts: [],
message: "Merge completed",
}
}
///|
fn get_commit_tree_id(
objects : &@lib.ObjectStore,
commit_id : @bit.ObjectId,
) -> @bit.ObjectId? raise @bit.GitError {
let obj = objects.get(commit_id)
guard obj is Some(commit_obj) else { return None }
if commit_obj.obj_type != @bit.ObjectType::Commit {
return None
}
let info = @bit.parse_commit(commit_obj.data)
Some(info.tree)
}
///|
fn is_ancestor(
objects : &@lib.ObjectStore,
ancestor : @bit.ObjectId,
commit_id : @bit.ObjectId,
) -> Bool raise @bit.GitError {
if ancestor == commit_id {
return true
}
let stack : Array[@bit.ObjectId] = [commit_id]
let seen : Map[String, Bool] = Map([])
while stack.length() > 0 {
let id = stack.pop()
guard id is Some(current) else { break }
let hex = current.to_hex()
if seen.contains(hex) {
continue
}
seen[hex] = true
if current == ancestor {
return true
}
let obj = objects.get(current)
guard obj is Some(commit_obj) else { continue }
if commit_obj.obj_type != @bit.ObjectType::Commit {
continue
}
let info = @bit.parse_commit(commit_obj.data)
for parent in info.parents {
stack.push(parent)
}
}
false
}
///|
fn find_merge_base(
objects : &@lib.ObjectStore,
a : @bit.ObjectId,
b : @bit.ObjectId,
) -> @bit.ObjectId? raise @bit.GitError {
let seen_a : Map[String, Bool] = Map([])
let stack_a : Array[@bit.ObjectId] = [a]
while stack_a.length() > 0 {
let id = stack_a.pop()
guard id is Some(current) else { break }
let hex = current.to_hex()
if seen_a.contains(hex) {
continue
}
seen_a[hex] = true
let obj = objects.get(current)
guard obj is Some(commit_obj) else { continue }
if commit_obj.obj_type != @bit.ObjectType::Commit {
continue
}
let info = @bit.parse_commit(commit_obj.data)
for parent in info.parents {
stack_a.push(parent)
}
}
let stack_b : Array[@bit.ObjectId] = [b]
while stack_b.length() > 0 {
let id = stack_b.pop()
guard id is Some(current) else { break }
let hex = current.to_hex()
if seen_a.contains(hex) {
return Some(current)
}
let obj = objects.get(current)
guard obj is Some(commit_obj) else { continue }
if commit_obj.obj_type != @bit.ObjectType::Commit {
continue
}
let info = @bit.parse_commit(commit_obj.data)
for parent in info.parents {
stack_b.push(parent)
}
}
None
}
///|
priv struct FileEntry {
id : @bit.ObjectId
mode : String
}
///|
fn collect_files_from_commit(
objects : &@lib.ObjectStore,
commit_id : @bit.ObjectId,
) -> Map[String, FileEntry] raise @bit.GitError {
let result : Map[String, FileEntry] = Map([])
let obj = objects.get(commit_id)
guard obj is Some(commit_obj) else { return result }
if commit_obj.obj_type != @bit.ObjectType::Commit {
return result
}
let info = @bit.parse_commit(commit_obj.data)
collect_tree_files(objects, info.tree, "", result)
result
}
///|
fn collect_tree_files(
objects : &@lib.ObjectStore,
tree_id : @bit.ObjectId,
prefix : String,
out : Map[String, FileEntry],
) -> Unit raise @bit.GitError {
let obj = objects.get(tree_id)
guard obj is Some(tree_obj) else { return () }
if tree_obj.obj_type != @bit.ObjectType::Tree {
return ()
}
let entries = @bit.parse_tree(tree_obj.data)
for entry in entries {
let path = if prefix.length() == 0 {
entry.name
} else {
prefix + "/" + entry.name
}
if entry.mode.has_prefix("04") {
collect_tree_files(objects, entry.id, path, out)
} else {
out[path] = { id: entry.id, mode: entry.mode }
}
}
}
///|
fn three_way_merge(
base : Map[String, FileEntry],
ours : Map[String, FileEntry],
theirs : Map[String, FileEntry],
) -> (Map[String, FileEntry], Array[String]) {
let merged : Map[String, FileEntry] = Map([])
let conflicts : Array[String] = []
let all_paths : Map[String, Bool] = Map([])
for path in base.keys() {
all_paths[path] = true
}
for path in ours.keys() {
all_paths[path] = true
}
for path in theirs.keys() {
all_paths[path] = true
}
for path in all_paths.keys() {
let b = base.get(path)
let o = ours.get(path)
let t = theirs.get(path)
if entry_eq(o, t) {
match o {
None => ()
Some(v) => merged[path] = v
}
} else if entry_eq(o, b) {
match t {
None => ()
Some(v) => merged[path] = v
}
} else if entry_eq(t, b) {
match o {
None => ()
Some(v) => merged[path] = v
}
} else {
conflicts.push(path)
}
}
(merged, conflicts)
}
///|
fn entry_eq(a : FileEntry?, b : FileEntry?) -> Bool {
match (a, b) {
(None, None) => true
(Some(x), Some(y)) => x.id == y.id && x.mode == y.mode
_ => false
}
}
///|
fn create_tree_from_files(
objects : &@lib.ObjectStore,
files : Map[String, FileEntry],
) -> @bit.ObjectId raise @bit.GitError {
let tree_map : Map[String, Array[(String, FileEntry)]] = Map([])
for path, entry in files {
let slash = path.rev_find("/")
let (dir, name) = match slash {
None => ("", path)
Some(idx) =>
(
String::unsafe_substring(path, start=0, end=idx),
String::unsafe_substring(path, start=idx + 1, end=path.length()),
)
}
if !tree_map.contains(dir) {
tree_map[dir] = []
}
let arr = tree_map.get(dir)
match arr {
Some(a) => a.push((name, entry))
None => ()
}
}
let tree_ids : Map[String, @bit.ObjectId] = Map([])
let dirs = tree_map.keys().to_array()
dirs.sort_by(fn(a, b) {
let a_depth = count_slashes(a)
let b_depth = count_slashes(b)
b_depth - a_depth
})
for dir in dirs {
let entries_arr = tree_map.get(dir)
guard entries_arr is Some(file_entries) else { continue }
let tree_entries : Array[@bit.TreeEntry] = []
for pair in file_entries {
let (name, entry) = pair
tree_entries.push(@bit.TreeEntry::new(entry.mode, name, entry.id))
}
for subdir, id in tree_ids {
let parent_dir = get_parent_dir(subdir)
if parent_dir == dir {
let name = get_base_name(subdir)
tree_entries.push(@bit.TreeEntry::new("040000", name, id))
}
}
tree_entries.sort_by((a, b) => String::compare(a.name, b.name))
let tree_bytes = @bit.serialize_tree(tree_entries)
let tree_id = objects.put(@bit.ObjectType::Tree, tree_bytes)
tree_ids[dir] = tree_id
}
tree_ids.get("") |> Option::unwrap()
}
///|
fn count_slashes(s : String) -> Int {
let mut count = 0
for c in s {
if c == '/' {
count = count + 1
}
}
count
}
///|
fn get_parent_dir(path : String) -> String {
match path.rev_find("/") {
None => ""
Some(idx) => String::unsafe_substring(path, start=0, end=idx)
}
}
///|
fn get_base_name(path : String) -> String {
match path.rev_find("/") {
None => path
Some(idx) =>
String::unsafe_substring(path, start=idx + 1, end=path.length())
}
}
///|
fn normalize_head_ref(ref_name : String) -> String {
if ref_name.has_prefix("refs/") {
ref_name
} else {
"refs/heads/" + ref_name
}
}
///|
fn resolve_ref_with_head_fallback(
refs : &@lib.RefStore,
ref_name : String,
) -> @bit.ObjectId? raise @bit.GitError {
let direct = refs.resolve(ref_name)
match direct {
Some(id) => Some(id)
None =>
if ref_name.has_prefix("refs/") {
None
} else {
refs.resolve("refs/heads/" + ref_name)
}
}
}
///|
fn source_missing_hint(pr : PullRequest) -> String {
let source_ref = match pr.source_ref {
Some(ref_name) => normalize_head_ref(ref_name)
None => normalize_head_ref(pr.source_branch)
}
let commit_hex = pr.source_commit.to_hex()
match pr.source_repo {
Some(repo) =>
"Source commit \{commit_hex} is not available locally. Fetch \{repo} \{source_ref} and retry."
None =>
"Source commit \{commit_hex} is not available locally. Fetch \{source_ref} and retry."
}
}
///|
fn resolve_pr_source_commit(
objects : &@lib.ObjectStore,
refs : &@lib.RefStore,
pr : PullRequest,
) -> Result[@bit.ObjectId, String] raise @bit.GitError {
match pr.source_ref {
Some(ref_name) => {
let from_source_ref = resolve_ref_with_head_fallback(refs, ref_name)
if from_source_ref is Some(id) {
return Ok(id)
}
}
None => ()
}
let from_source_branch = resolve_ref_with_head_fallback(
refs,
pr.source_branch,
)
if from_source_branch is Some(id) {
return Ok(id)
}
if objects.has(pr.source_commit) {
return Ok(pr.source_commit)
}
Err(source_missing_hint(pr))
}
///|
fn update_branch_ref(
refs : &@lib.RefStore,
branch : String,
commit_id : @bit.ObjectId,
) -> Unit raise @bit.GitError {
let ref_name = if branch.has_prefix("refs/") {
branch
} else {
"refs/heads/" + branch
}
refs.update(ref_name, Some(commit_id))
}
///|
fn mark_pr_merged(
prs : Hub,
objects : &@lib.ObjectStore,
refs : &@lib.RefStore,
clock : &@lib.Clock,
pr_id : String,
existing : PullRequest,
merge_commit_id : @bit.ObjectId,
) -> Unit raise @bit.GitError {
let updated = PullRequest::new(
existing.id,
existing.title,
existing.body,
existing.source_branch,
existing.source_commit,
existing.target_branch,
existing.target_commit,
existing.author,
existing.created_at,
clock.now(),
PrState::Merged,
existing.labels,
merge_commit=Some(merge_commit_id),
source_repo=existing.source_repo,
source_ref=existing.source_ref,
)
ignore(
prs.store.put_record(
objects,
refs,
clock,
work_item_meta_key(pr_id),
canonical_work_item_record_kind(),
updated.to_work_item().serialize(),
existing.author,
),
)
}