///| Merge implementation with content-level 3-way merge
///|
pub enum MergeStatus {
AlreadyUpToDate
FastForward
Merged
Conflicted
}
///|
pub struct MergeResult {
status : MergeStatus
commit_id : @bit.ObjectId?
conflicts : Array[String]
/// Conflict type per path: "content", "rename/delete", "rename/add", etc.
conflict_types : Map[String, String]
}
///|
priv struct MergePreservedGitlink {
path : String
backup_path : String
}
///|
fn merge_preserve_removed_gitlinks(
fs : &@bit.FileSystem,
rfs : &@bit.RepoFileSystem,
root : String,
git_dir : String,
files : Map[String, TreeFileEntry],
) -> Array[MergePreservedGitlink] raise @bit.GitError {
let entries = read_index_entries(rfs, git_dir)
let preserved : Array[MergePreservedGitlink] = []
let backup_root = ".bit-merge-preserve"
let mut backup_index = 0
for entry in entries {
if files.contains(entry.path) || !is_gitlink_mode_int(entry.mode) {
continue
}
let full_path = join_path(root, entry.path)
let git_marker = join_path(full_path, ".git")
if !rfs.is_dir(full_path) {
continue
}
if !(rfs.is_file(git_marker) || rfs.is_dir(git_marker)) {
continue
}
let backup_path = join_path(backup_root, backup_index.to_string())
let backup_abs = join_path(root, backup_path)
mv_copy_directory_recursive(fs, rfs, full_path, backup_abs)
let backup_gitfile = join_path(backup_abs, ".git")
if rfs.is_file(git_marker) && rfs.is_file(backup_gitfile) {
mv_rewrite_gitfile_after_directory_move(
fs, rfs, full_path, backup_abs, backup_gitfile,
)
}
remove_worktree_path_recursive(fs, rfs, full_path)
preserved.push({ path: entry.path, backup_path })
backup_index += 1
}
preserved
}
///|
fn merge_restore_preserved_gitlinks(
fs : &@bit.FileSystem,
rfs : &@bit.RepoFileSystem,
root : String,
preserved : Array[MergePreservedGitlink],
) -> Unit raise @bit.GitError {
for item in preserved {
let full_path = join_path(root, item.path)
if rfs.is_dir(full_path) {
continue
}
let backup_abs = join_path(root, item.backup_path)
if !rfs.is_dir(backup_abs) {
continue
}
mv_copy_directory_recursive(fs, rfs, backup_abs, full_path)
let backup_gitfile = join_path(backup_abs, ".git")
let full_gitfile = join_path(full_path, ".git")
if rfs.is_file(backup_gitfile) && rfs.is_file(full_gitfile) {
mv_rewrite_gitfile_after_directory_move(
fs, rfs, backup_abs, full_path, full_gitfile,
)
}
remove_worktree_path_recursive(fs, rfs, backup_abs)
}
let backup_root = join_path(root, ".bit-merge-preserve")
if rfs.is_dir(backup_root) {
remove_worktree_path_recursive(fs, rfs, backup_root)
}
}
///|
fn merge_fast_forward(
fs : &@bit.FileSystem,
rfs : &@bit.RepoFileSystem,
root : String,
git_dir : String,
target : @bit.ObjectId,
) -> Unit raise @bit.GitError {
let db = ObjectDb::load(rfs, git_dir)
let files = collect_tree_files_from_commit(db, rfs, target)
let preserved = merge_preserve_removed_gitlinks(fs, rfs, root, git_dir, files)
write_worktree_from_files(
db,
fs,
rfs,
root,
git_dir,
files,
remove_missing=true,
preserve_removed_gitlinks=true,
)
let entries = tree_files_to_index(db, rfs, files)
write_index_entries(fs, git_dir, entries)
update_head_ref(fs, rfs, git_dir, target)
merge_restore_preserved_gitlinks(fs, rfs, root, preserved)
}
///|
pub fn merge(
fs : &@bit.FileSystem,
rfs : &@bit.RepoFileSystem,
root : String,
target : @bit.ObjectId,
message : String,
author : String,
timestamp : Int64,
ours_label? : String = "HEAD",
theirs_label? : String = "incoming",
) -> MergeResult raise @bit.GitError {
let git_dir = join_path(root, ".git")
let head = match resolve_head_commit(rfs, git_dir) {
None =>
match read_head_ref(rfs, git_dir) {
Branch(_) => {
merge_fast_forward(fs, rfs, root, git_dir, target)
return {
status: FastForward,
commit_id: Some(target),
conflicts: [],
conflict_types: Map([]),
}
}
Detached(_) => raise @bit.GitError::InvalidObject("HEAD not found")
}
Some(id) => id
}
if head == target {
return {
status: AlreadyUpToDate,
commit_id: Some(head),
conflicts: [],
conflict_types: Map([]),
}
}
let db = ObjectDb::load(rfs, git_dir)
if merge_is_ancestor(db, rfs, head, target) {
merge_fast_forward(fs, rfs, root, git_dir, target)
return {
status: FastForward,
commit_id: Some(target),
conflicts: [],
conflict_types: Map([]),
}
}
if merge_is_ancestor(db, rfs, target, head) {
return {
status: AlreadyUpToDate,
commit_id: Some(head),
conflicts: [],
conflict_types: Map([]),
}
}
let base = merge_find_base(db, rfs, head, target)
let base_files = match base {
None => Map([])
Some(id) => collect_tree_files_from_commit(db, rfs, id)
}
let ours = collect_tree_files_from_commit(db, rfs, head)
let theirs = collect_tree_files_from_commit(db, rfs, target)
let file_result = merge_files(base_files, ours, theirs, db, rfs)
let merged = file_result.merged
let conflicts = file_result.conflicts
let all_conflict_types = file_result.conflict_types
let rename_conflict_stages : Map[String, (TreeFileEntry, TreeFileEntry)] = Map([],
)
// Try content-level merge for files modified on both sides
for candidate in file_result.needs_content_merge {
let base_content = match candidate.base_id {
Some(id) => merge_get_blob_text(db, rfs, id)
None => ""
}
let ours_content = merge_get_blob_text(db, rfs, candidate.ours_id)
let theirs_content = merge_get_blob_text(db, rfs, candidate.theirs_id)
// Skip binary files
let ours_data = merge_get_blob_data(db, rfs, candidate.ours_id)
let theirs_data = merge_get_blob_data(db, rfs, candidate.theirs_id)
if @diff3.is_binary_content(ours_data) ||
@diff3.is_binary_content(theirs_data) {
conflicts.push(candidate.path)
if !all_conflict_types.contains(candidate.path) {
all_conflict_types[candidate.path] = "content"
}
continue
}
let result = @diff3.content_merge(
base_content,
ours_content,
theirs_content,
@diff3.ContentMergeOptions::default().with_labels(
ours=ours_label,
base="",
theirs=theirs_label,
),
)
if result.has_conflicts {
// Write conflict markers to worktree
let full_path = join_path(root, candidate.path)
let dir = rebase_parent_dir(full_path)
fs.mkdir_p(dir)
fs.write_string(full_path, result.content)
conflicts.push(candidate.path)
if !all_conflict_types.contains(candidate.path) {
all_conflict_types[candidate.path] = "content"
}
} else {
// Clean merge - create new blob
let content_bytes = @utf8.encode(result.content)
let (blob_id, compressed) = @bit.create_blob(content_bytes)
write_object_bytes(fs, git_dir, blob_id, compressed)
// If this path has a rename/add conflict, store resolved as stage 2
if all_conflict_types.get(candidate.path).unwrap_or("") == "rename/add" {
let resolved_entry : TreeFileEntry = {
id: blob_id,
mode: candidate.mode,
}
// Find theirs' independently added file for stage 3
let theirs_added = match theirs.get(candidate.path) {
Some(e) => e
None => resolved_entry
}
rename_conflict_stages[candidate.path] = (resolved_entry, theirs_added)
// Write conflict markers for worktree
let ours_text = merge_get_blob_text(db, rfs, blob_id)
let theirs_text = match theirs.get(candidate.path) {
Some(e) => merge_get_blob_text(db, rfs, e.id)
None => ""
}
let conflict_result = @diff3.content_merge(
"",
ours_text,
theirs_text,
@diff3.ContentMergeOptions::default().with_labels(
ours=ours_label,
base="",
theirs=theirs_label,
),
)
let full_path = join_path(root, candidate.path)
let dir = rebase_parent_dir(full_path)
fs.mkdir_p(dir)
fs.write_string(full_path, conflict_result.content)
} else {
merged[candidate.path] = { id: blob_id, mode: candidate.mode }
}
}
}
// Handle rename/rename(2to1) conflicts
for candidate in file_result.rename_2to1 {
// Resolve ours side: merge(base[ours_old], ours[path], theirs[ours_old])
let ours_base_id = base_files
.get(candidate.ours_old_path)
.map(fn(e) { e.id })
let ours_at_path = ours.get(candidate.path)
let theirs_at_ours_old = theirs.get(candidate.ours_old_path)
let ours_resolved = merge_resolve_rename_content(
db, rfs, fs, git_dir, ours_base_id, ours_at_path, theirs_at_ours_old,
)
// Resolve theirs side: merge(base[theirs_old], ours[theirs_old], theirs[path])
let theirs_base_id = base_files
.get(candidate.theirs_old_path)
.map(fn(e) { e.id })
let ours_at_theirs_old = ours.get(candidate.theirs_old_path)
let theirs_at_path = theirs.get(candidate.path)
let theirs_resolved = merge_resolve_rename_content(
db, rfs, fs, git_dir, theirs_base_id, ours_at_theirs_old, theirs_at_path,
)
// Now merge the two resolved versions
let ours_text = match ours_resolved {
Some((id, _)) => merge_get_blob_text(db, rfs, id)
None =>
match ours_at_path {
Some(e) => merge_get_blob_text(db, rfs, e.id)
None => ""
}
}
let theirs_text = match theirs_resolved {
Some((id, _)) => merge_get_blob_text(db, rfs, id)
None =>
match theirs_at_path {
Some(e) => merge_get_blob_text(db, rfs, e.id)
None => ""
}
}
let ours_blob_id = match ours_resolved {
Some((id, _)) => id
None =>
match ours_at_path {
Some(e) => e.id
None => @bit.ObjectId::zero()
}
}
let theirs_blob_id = match theirs_resolved {
Some((id, _)) => id
None =>
match theirs_at_path {
Some(e) => e.id
None => @bit.ObjectId::zero()
}
}
if ours_blob_id == theirs_blob_id {
// Same resolved content - no conflict
merged[candidate.path] = { id: ours_blob_id, mode: candidate.mode }
} else {
// Different content - final merge with conflict markers
// Use common prefix lines as base so shared content stays outside markers
let common_base = merge_common_line_prefix(ours_text, theirs_text)
let final_result = @diff3.content_merge(
common_base,
ours_text,
theirs_text,
@diff3.ContentMergeOptions::default().with_labels(
ours=ours_label,
base="",
theirs=theirs_label,
),
)
let full_path = join_path(root, candidate.path)
let dir = rebase_parent_dir(full_path)
fs.mkdir_p(dir)
fs.write_string(full_path, final_result.content)
conflicts.push(candidate.path)
all_conflict_types[candidate.path] = "rename/rename"
// Store stage entries for index
rename_conflict_stages[candidate.path] = (
{ id: ours_blob_id, mode: candidate.mode },
{ id: theirs_blob_id, mode: candidate.mode },
)
}
}
// Detect file/directory conflicts: a file path that collides with a directory
let file_dir_renames : Map[String, String] = Map([])
let merged_paths : Array[String] = []
for path, _ in merged {
merged_paths.push(path)
}
for path, _ in merged {
let prefix = path + "/"
for other_path in merged_paths {
if other_path.has_prefix(prefix) {
let new_path = path + "~HEAD"
file_dir_renames[path] = new_path
break
}
}
}
for old_path, new_path in file_dir_renames {
match merged.get(old_path) {
Some(entry) => {
merged.remove(old_path)
merged[new_path] = entry
conflicts.push(new_path)
all_conflict_types[new_path] = "file/directory"
}
None => ()
}
}
if conflicts.length() > 0 {
write_worktree_from_files(
db,
fs,
rfs,
root,
git_dir,
merged,
remove_missing=false,
)
// Remove worktree files for paths that were renamed away
for path in file_result.removed_paths {
let full_path = join_path(root, path)
if rfs.is_file(full_path) {
fs.remove_file(full_path) catch {
_ => ()
}
}
}
let entries = merge_conflicted_index_entries_with_renames(
db, rfs, base_files, ours, theirs, merged, conflicts, rename_conflict_stages,
)
write_index_stage_entries(fs, git_dir, entries)
fs.write_string(git_dir + "/MERGE_HEAD", target.to_hex() + "\n")
fs.write_string(git_dir + "/ORIG_HEAD", head.to_hex() + "\n")
fs.write_string(git_dir + "/MERGE_MSG", message + "\n")
conflicts.sort()
return {
status: Conflicted,
commit_id: None,
conflicts,
conflict_types: all_conflict_types,
}
}
write_worktree_from_files(
db,
fs,
rfs,
root,
git_dir,
merged,
remove_missing=true,
)
let entries = tree_files_to_index(db, rfs, merged)
write_index_entries(fs, git_dir, entries)
let tree_id = write_tree_from_index(fs, rfs, git_dir, entries)
let commit = @bit.Commit::new(
tree_id,
[head, target],
author,
timestamp,
"+0000",
author,
timestamp,
"+0000",
message,
)
let (commit_id, compressed) = @bit.create_commit(commit)
write_object_bytes(fs, git_dir, commit_id, compressed)
update_head_ref(fs, rfs, git_dir, commit_id)
{
status: Merged,
commit_id: Some(commit_id),
conflicts: [],
conflict_types: Map([]),
}
}
///|
fn merge_tree_file_to_index_entry(
db : ObjectDb,
fs : &@bit.RepoFileSystem,
path : String,
info : TreeFileEntry,
) -> IndexEntry raise @bit.GitError {
let files : Map[String, TreeFileEntry] = Map([])
files[path] = info
let entries = tree_files_to_index(db, fs, files)
if entries.length() == 0 {
raise @bit.GitError::InvalidObject("Missing merge index entry: " + path)
}
entries[0]
}
///|
fn merge_is_ancestor(
db : ObjectDb,
fs : &@bit.RepoFileSystem,
ancestor : @bit.ObjectId,
commit_id : @bit.ObjectId,
) -> Bool raise @bit.GitError {
merge_base_is_ancestor(db, fs, ancestor, commit_id)
}
///|
fn merge_find_base(
db : ObjectDb,
fs : &@bit.RepoFileSystem,
a : @bit.ObjectId,
b : @bit.ObjectId,
) -> @bit.ObjectId? raise @bit.GitError {
let seen : Map[String, Bool] = Map([])
let stack_a : Array[@bit.ObjectId] = [a]
while stack_a.length() > 0 {
let id = match stack_a.pop() {
None => raise @bit.GitError::InvalidObject("Empty stack")
Some(v) => v
}
let hex = id.to_hex()
if seen.contains(hex) {
continue
}
seen[hex] = true
let parents = merge_commit_parents(db, fs, id)
for p in parents {
stack_a.push(p)
}
}
let stack_b : Array[@bit.ObjectId] = [b]
while stack_b.length() > 0 {
let id = match stack_b.pop() {
None => raise @bit.GitError::InvalidObject("Empty stack")
Some(v) => v
}
let hex = id.to_hex()
if seen.contains(hex) {
return Some(id)
}
let parents = merge_commit_parents(db, fs, id)
for p in parents {
stack_b.push(p)
}
}
None
}
///|
fn merge_commit_parents(
db : ObjectDb,
fs : &@bit.RepoFileSystem,
commit_id : @bit.ObjectId,
) -> Array[@bit.ObjectId] raise @bit.GitError {
let obj = db.get(fs, commit_id)
match obj {
None => raise @bit.GitError::InvalidObject("Missing commit object")
Some(o) => {
if o.obj_type != @bit.ObjectType::Commit {
raise @bit.GitError::InvalidObject("Object is not a commit")
}
let info = @bit.parse_commit(o.data)
info.parents
}
}
}
///|
priv struct MergeFilesResult {
merged : Map[String, TreeFileEntry]
conflicts : Array[String]
needs_content_merge : Array[ContentMergeCandidate]
rename_2to1 : Array[Rename2to1Candidate]
/// Conflict type per path (e.g. "rename/delete", "rename/add")
conflict_types : Map[String, String]
/// Paths that were renamed away and should be removed from worktree
removed_paths : Array[String]
}
///|
priv struct ContentMergeCandidate {
path : String
base_id : @bit.ObjectId?
ours_id : @bit.ObjectId
theirs_id : @bit.ObjectId
mode : Int
}
///|
/// Two different files both renamed to the same target path.
/// Requires resolving each side's rename/modify first, then merging results.
priv struct Rename2to1Candidate {
path : String
ours_old_path : String
theirs_old_path : String
mode : Int
}
///|
fn merge_files(
base : Map[String, TreeFileEntry],
ours : Map[String, TreeFileEntry],
theirs : Map[String, TreeFileEntry],
db : ObjectDb,
rfs : &@bit.RepoFileSystem,
) -> MergeFilesResult {
let merged : Map[String, TreeFileEntry] = Map([])
let conflicts : Array[String] = []
let needs_content_merge : Array[ContentMergeCandidate] = []
let rename_2to1 : Array[Rename2to1Candidate] = []
let conflict_types : Map[String, String] = Map([])
let removed_paths : Array[String] = []
// Step 1: Detect renames on each side
let ours_renames = merge_detect_renames(base, ours, db, rfs)
let theirs_renames = merge_detect_renames(base, theirs, db, rfs)
// Paths that are handled by rename logic (skip in normal processing)
let handled_paths : Map[String, Bool] = Map([])
// Step 1b: Detect rename/rename(2to1) - different files renamed to same target
let ours_new_to_old : Map[String, String] = Map([])
for old_path, new_path in ours_renames.old_to_new {
ours_new_to_old[new_path] = old_path
}
for theirs_old, theirs_new in theirs_renames.old_to_new {
match ours_new_to_old.get(theirs_new) {
Some(ours_old) =>
if ours_old != theirs_old {
// rename/rename(2to1): different files both renamed to same path
let mode = match ours.get(theirs_new) {
Some(e) => e.mode
None => 0o100644
}
rename_2to1.push({
path: theirs_new,
ours_old_path: ours_old,
theirs_old_path: theirs_old,
mode,
})
handled_paths[ours_old] = true
handled_paths[theirs_old] = true
handled_paths[theirs_new] = true
removed_paths.push(ours_old)
removed_paths.push(theirs_old)
}
None => ()
}
}
// Step 2: Process renames on ours side (skip already handled by Step 1b)
for old_path, new_path in ours_renames.old_to_new {
if handled_paths.contains(old_path) || handled_paths.contains(new_path) {
continue
}
let base_entry = base.get(old_path)
let ours_entry = ours.get(new_path)
// Check if theirs also renamed the same file
match theirs_renames.old_to_new.get(old_path) {
Some(theirs_new_path) =>
if theirs_new_path == new_path {
// Both sides renamed to same path - check content
let theirs_entry = theirs.get(theirs_new_path)
if merge_entry_eq(ours_entry, theirs_entry) {
// Same rename, same content - take it
match ours_entry {
Some(v) => merged[new_path] = v
None => ()
}
} else {
// Same target path, possibly different content - content merge
match (ours_entry, theirs_entry) {
(Some(o), Some(t)) =>
needs_content_merge.push({
path: new_path,
base_id: base_entry.map(fn(e) { e.id }),
ours_id: o.id,
theirs_id: t.id,
mode: o.mode,
})
_ => ()
}
}
handled_paths[old_path] = true
handled_paths[new_path] = true
handled_paths[theirs_new_path] = true
} else {
// Renamed to different paths: rename/rename(1to2) conflict
// Put both versions in merged for worktree
match ours_entry {
Some(v) => merged[new_path] = v
None => ()
}
match theirs.get(theirs_new_path) {
Some(v) => merged[theirs_new_path] = v
None => ()
}
conflicts.push(new_path)
conflict_types[new_path] = "rename/rename"
handled_paths[old_path] = true
handled_paths[new_path] = true
handled_paths[theirs_new_path] = true
}
None => {
// Only ours renamed. Check what theirs did with old_path
let is_add_source = ours_renames.add_source_paths.contains(old_path)
let theirs_entry = theirs.get(old_path)
match (base_entry, ours_entry, theirs_entry) {
(Some(b), Some(o), Some(t)) =>
if b.id == t.id {
// Theirs didn't modify - just take ours rename
merged[new_path] = o
} else if is_add_source &&
theirs_renames.add_source_paths.contains(old_path) {
// Both sides replaced old_path with new content; the original
// was renamed. Handle renamed content at new_path, and the
// new files at old_path will be merged in Step 4.
merged[new_path] = o
} else {
// Theirs modified at old_path, ours renamed to new_path
// -> content merge at new_path
needs_content_merge.push({
path: new_path,
base_id: Some(b.id),
ours_id: o.id,
theirs_id: t.id,
mode: o.mode,
})
}
(_, Some(o), None) => {
// Theirs deleted old_path, ours renamed: rename/delete conflict
merged[new_path] = o
conflicts.push(new_path)
conflict_types[new_path] = "rename/delete"
}
_ =>
match ours_entry {
Some(v) => merged[new_path] = v
None => ()
}
}
// For add-source: include the new file at old_path
if is_add_source {
match ours.get(old_path) {
Some(new_file) => merged[old_path] = new_file
None => ()
}
}
// Check for rename/add-dest: theirs independently added a file at new_path
if !base.contains(new_path) {
match theirs.get(new_path) {
Some(_) => {
// Theirs added a file at same path as our rename target
conflicts.push(new_path)
conflict_types[new_path] = "rename/add"
}
None => ()
}
}
handled_paths[old_path] = true
handled_paths[new_path] = true
// Only mark old_path for removal if it's a clean rename (no conflict, no add-source)
if !is_add_source && !conflict_types.contains(new_path) {
removed_paths.push(old_path)
}
}
}
}
// Step 3: Process renames on theirs side (skip already handled)
for old_path, new_path in theirs_renames.old_to_new {
if handled_paths.contains(old_path) || handled_paths.contains(new_path) {
continue
}
let base_entry = base.get(old_path)
let theirs_entry = theirs.get(new_path)
let is_add_source = theirs_renames.add_source_paths.contains(old_path)
// ours didn't rename (already checked above)
let ours_entry = ours.get(old_path)
match (base_entry, ours_entry, theirs_entry) {
(Some(b), Some(o), Some(t)) =>
if b.id == o.id {
// Ours didn't modify - just take theirs rename
merged[new_path] = t
} else if is_add_source &&
ours_renames.add_source_paths.contains(old_path) {
// Both sides replaced; handled in Step 2
merged[new_path] = t
} else {
// Ours modified at old_path, theirs renamed to new_path
// -> content merge at new_path
needs_content_merge.push({
path: new_path,
base_id: Some(b.id),
ours_id: o.id,
theirs_id: t.id,
mode: t.mode,
})
}
(_, None, Some(t)) => {
// Ours deleted old_path, theirs renamed: rename/delete conflict
merged[new_path] = t
conflicts.push(new_path)
conflict_types[new_path] = "rename/delete"
}
_ =>
match theirs_entry {
Some(v) => merged[new_path] = v
None => ()
}
}
// For add-source: include the new file at old_path
if is_add_source {
match theirs.get(old_path) {
Some(new_file) => merged[old_path] = new_file
None => ()
}
}
// Check for rename/add-dest: ours independently added a file at new_path
if !base.contains(new_path) {
match ours.get(new_path) {
Some(_) => {
conflicts.push(new_path)
conflict_types[new_path] = "rename/add"
}
None => ()
}
}
handled_paths[old_path] = true
handled_paths[new_path] = true
if !is_add_source && !conflict_types.contains(new_path) {
removed_paths.push(old_path)
}
}
// Step 4: Process remaining paths with standard logic
let all_paths : Map[String, Bool] = Map([])
for path, _ in base {
if !handled_paths.contains(path) {
all_paths[path] = true
}
}
for path, _ in ours {
if !handled_paths.contains(path) {
all_paths[path] = true
}
}
for path, _ in theirs {
if !handled_paths.contains(path) {
all_paths[path] = true
}
}
for path, _ in all_paths {
let b = base.get(path)
let o = ours.get(path)
let t = theirs.get(path)
if merge_entry_eq(o, t) {
match o {
None => ()
Some(v) => merged[path] = v
}
} else if merge_entry_eq(o, b) {
match t {
None => ()
Some(v) => merged[path] = v
}
} else if merge_entry_eq(t, b) {
match o {
None => ()
Some(v) => merged[path] = v
}
} else {
// Both sides modified - try content merge if both have the file
match (o, t) {
(Some(ours_entry), Some(theirs_entry)) => {
let base_id = match b {
Some(base_entry) => Some(base_entry.id)
None => None
}
needs_content_merge.push({
path,
base_id,
ours_id: ours_entry.id,
theirs_id: theirs_entry.id,
mode: ours_entry.mode,
})
}
_ => {
// One side deleted, other modified - file-level conflict
conflicts.push(path)
conflict_types[path] = "modify/delete"
}
}
}
}
{
merged,
conflicts,
needs_content_merge,
rename_2to1,
conflict_types,
removed_paths,
}
}
///|
fn merge_get_blob_text(
db : ObjectDb,
fs : &@bit.RepoFileSystem,
id : @bit.ObjectId,
) -> String {
get_blob_content(db, fs, id)
}
///|
fn merge_get_blob_data(
db : ObjectDb,
fs : &@bit.RepoFileSystem,
id : @bit.ObjectId,
) -> Bytes {
let obj = db.get(fs, id) catch { _ => return Default::default() }
match obj {
Some(o) =>
if o.obj_type == @bit.ObjectType::Blob {
o.data
} else {
Default::default()
}
None => Default::default()
}
}
///|
fn merge_entry_eq(a : TreeFileEntry?, b : TreeFileEntry?) -> Bool {
match (a, b) {
(None, None) => true
(Some(x), Some(y)) => x.id == y.id && x.mode == y.mode
_ => false
}
}
///|
/// Extract common prefix lines from two texts for use as merge base.
fn merge_common_line_prefix(a : String, b : String) -> String {
let a_arr : Array[String] = []
for part in a.split("\n") {
a_arr.push(part.to_owned())
}
let b_arr : Array[String] = []
for part in b.split("\n") {
b_arr.push(part.to_owned())
}
let result = StringBuilder::new()
let limit = if a_arr.length() < b_arr.length() {
a_arr.length()
} else {
b_arr.length()
}
let mut count = 0
for i in 0.. (@bit.ObjectId, Int)? raise @bit.GitError {
let ours_id = match ours_entry {
Some(e) => e.id
None => return None
}
let theirs_id = match theirs_entry {
Some(e) => e.id
None => return None
}
match base_id {
Some(b_id) =>
if b_id == theirs_id {
// Theirs didn't modify, take ours
return Some((ours_id, ours_entry.unwrap().mode))
} else if b_id == ours_id {
// Ours didn't modify, take theirs
return Some((theirs_id, theirs_entry.unwrap().mode))
} else if ours_id == theirs_id {
return Some((ours_id, ours_entry.unwrap().mode))
}
None =>
if ours_id == theirs_id {
return Some((ours_id, ours_entry.unwrap().mode))
}
}
// Both modified - content merge
let base_content = match base_id {
Some(id) => merge_get_blob_text(db, rfs, id)
None => ""
}
let ours_content = merge_get_blob_text(db, rfs, ours_id)
let theirs_content = merge_get_blob_text(db, rfs, theirs_id)
let result = @diff3.content_merge(
base_content,
ours_content,
theirs_content,
@diff3.ContentMergeOptions::default(),
)
let content_bytes = @utf8.encode(result.content)
let (blob_id, compressed) = @bit.create_blob(content_bytes)
write_object_bytes(fs, git_dir, blob_id, compressed)
Some((blob_id, ours_entry.unwrap().mode))
}
///|
/// Create index stage entries for conflicts, including rename/rename(2to1) stages.
fn merge_conflicted_index_entries_with_renames(
db : ObjectDb,
fs : &@bit.RepoFileSystem,
base : Map[String, TreeFileEntry],
ours : Map[String, TreeFileEntry],
theirs : Map[String, TreeFileEntry],
merged : Map[String, TreeFileEntry],
conflicts : Array[String],
rename_conflict_stages : Map[String, (TreeFileEntry, TreeFileEntry)],
) -> Array[IndexStageEntry] raise @bit.GitError {
let out : Array[IndexStageEntry] = []
let conflict_set : Map[String, Bool] = Map([])
for path in conflicts {
conflict_set[path] = true
}
let merged_entries = tree_files_to_index(db, fs, merged)
for entry in merged_entries {
if !conflict_set.contains(entry.path) {
out.push({ entry, stage: 0 })
}
}
for path in conflicts {
match rename_conflict_stages.get(path) {
Some(stages) => {
// rename/rename(2to1): stage 2 = resolved ours, stage 3 = resolved theirs
let (ours_stage, theirs_stage) = stages
out.push({
entry: merge_tree_file_to_index_entry(db, fs, path, ours_stage),
stage: 2,
})
out.push({
entry: merge_tree_file_to_index_entry(db, fs, path, theirs_stage),
stage: 3,
})
}
None => {
// Standard conflict: base/ours/theirs at same path
match base.get(path) {
Some(info) =>
out.push({
entry: merge_tree_file_to_index_entry(db, fs, path, info),
stage: 1,
})
None => ()
}
match ours.get(path) {
Some(info) =>
out.push({
entry: merge_tree_file_to_index_entry(db, fs, path, info),
stage: 2,
})
None => ()
}
match theirs.get(path) {
Some(info) =>
out.push({
entry: merge_tree_file_to_index_entry(db, fs, path, info),
stage: 3,
})
None => ()
}
}
}
}
out
}