diff --git a/app/src/lib/branch/BranchLanePopupMenu.svelte b/app/src/lib/branch/BranchLanePopupMenu.svelte index 2c772c7331..2a5aa4c30d 100644 --- a/app/src/lib/branch/BranchLanePopupMenu.svelte +++ b/app/src/lib/branch/BranchLanePopupMenu.svelte @@ -7,13 +7,15 @@ import { projectAiGenEnabled } from '$lib/config/config'; import Button from '$lib/shared/Button.svelte'; import Modal from '$lib/shared/Modal.svelte'; + import Select from '$lib/shared/Select.svelte'; + import SelectItem from '$lib/shared/SelectItem.svelte'; import TextBox from '$lib/shared/TextBox.svelte'; import Toggle from '$lib/shared/Toggle.svelte'; import { User } from '$lib/stores/user'; import { normalizeBranchName } from '$lib/utils/branch'; import { getContext, getContextStore } from '$lib/utils/context'; import { BranchController } from '$lib/vbranches/branchController'; - import { Branch } from '$lib/vbranches/types'; + import { Branch, type NameConflictResolution } from '$lib/vbranches/types'; import { createEventDispatcher } from 'svelte'; export let visible: boolean; @@ -51,8 +53,96 @@ function close() { visible = false; } + + let unapplyBranchModal: Modal; + + type ResolutionVariants = NameConflictResolution['type']; + + const resolutions: { value: ResolutionVariants; label: string }[] = [ + { + value: 'overwrite', + label: 'Overwrite the existing branch' + }, + { + value: 'suffix', + label: 'Suffix the branch name' + }, + { + value: 'rename', + label: 'Use a new name' + } + ]; + + let selectedResolution: ResolutionVariants = resolutions[0].value; + let newBranchName = ''; + + function unapplyBranchWithSelectedResolution() { + let resolution: NameConflictResolution | undefined; + if (selectedResolution === 'rename') { + resolution = { + type: selectedResolution, + value: newBranchName + }; + } else { + resolution = { + type: selectedResolution, + value: undefined + }; + } + + branchController.convertToRealBranch(branch.id, resolution); + + unapplyBranchModal.close(); + } + + const remoteBranches = branchController.remoteBranchService.branches$; + + function tryUnapplyBranch() { + if ($remoteBranches.find((b) => b.name.endsWith(normalizeBranchName(branch.name)))) { + unapplyBranchModal.show(); + } else { + // No resolution required + branchController.convertToRealBranch(branch.id); + } + } + +
+ + + + {#if selectedResolution === 'rename'} + + {/if} +
+ {#snippet controls()} + + + {/snippet} +
+ {#if visible} @@ -71,7 +161,7 @@ { - if (branch.id) branchController.unapplyBranch(branch.id); + tryUnapplyBranch(); close(); }} /> @@ -186,3 +276,16 @@ {/snippet} + + diff --git a/app/src/lib/vbranches/branchController.ts b/app/src/lib/vbranches/branchController.ts index ff13a0f307..7d70444718 100644 --- a/app/src/lib/vbranches/branchController.ts +++ b/app/src/lib/vbranches/branchController.ts @@ -4,7 +4,7 @@ import * as toasts from '$lib/utils/toasts'; import posthog from 'posthog-js'; import type { RemoteBranchService } from '$lib/stores/remoteBranches'; import type { BaseBranchService } from './baseBranch'; -import type { Branch, Hunk, LocalFile } from './types'; +import type { Branch, Hunk, LocalFile, NameConflictResolution } from './types'; import type { VirtualBranchService } from './virtualBranch'; export class BranchController { @@ -180,10 +180,17 @@ export class BranchController { } } - async unapplyBranch(branchId: string) { + async convertToRealBranch( + branchId: string, + nameConflictResolution: NameConflictResolution = { type: 'suffix', value: undefined } + ) { try { - // TODO: make this optimistic again. - await invoke('unapply_branch', { projectId: this.projectId, branch: branchId }); + await invoke('convert_to_real_branch', { + projectId: this.projectId, + branch: branchId, + nameConflictResolution + }); + this.remoteBranchService.reload(); } catch (err) { showError('Failed to unapply branch', err); } @@ -284,25 +291,6 @@ You can find them in the 'Branches' sidebar in order to resolve conflicts.`; } } - async cherryPick(branchId: string, targetCommitOid: string) { - try { - await invoke('cherry_pick_onto_virtual_branch', { - projectId: this.projectId, - branchId, - targetCommitOid - }); - } catch (err: any) { - // TODO: Probably we wanna have error code checking in a more generic way - if (err.code === 'errors.commit.signing_failed') { - showSignError(err); - } else { - showError('Failed to cherry-pick commit', err); - } - } finally { - this.targetBranchService.reload(); - } - } - async markResolved(path: string) { try { await invoke('mark_resolved', { projectId: this.projectId, path }); diff --git a/app/src/lib/vbranches/types.ts b/app/src/lib/vbranches/types.ts index 571a00a411..ee4d138903 100644 --- a/app/src/lib/vbranches/types.ts +++ b/app/src/lib/vbranches/types.ts @@ -461,3 +461,17 @@ export class BaseBranch { return this.repoBaseUrl.includes('gitlab.com'); } } + +export type NameConflictResolution = + | { + type: 'suffix'; + value: undefined; + } + | { + type: 'overwrite'; + value: undefined; + } + | { + type: 'rename'; + value: string; + }; diff --git a/app/src/lib/vbranches/virtualBranch.ts b/app/src/lib/vbranches/virtualBranch.ts index 1744001143..56ee16b9ef 100644 --- a/app/src/lib/vbranches/virtualBranch.ts +++ b/app/src/lib/vbranches/virtualBranch.ts @@ -58,10 +58,8 @@ export class VirtualBranchService { tap((branches) => { branches.forEach((branch) => { branch.files.sort((a) => (a.conflicted ? -1 : 0)); - branch.isMergeable = invoke('can_apply_virtual_branch', { - projectId: projectId, - branchId: branch.id - }); + // This is always true now + branch.isMergeable = Promise.resolve(true); }); this.fresh$.next(); // Notification for fresh reload }), diff --git a/crates/gitbutler-core/Cargo.toml b/crates/gitbutler-core/Cargo.toml index 65aa3a0f40..b8bf0eeefe 100644 --- a/crates/gitbutler-core/Cargo.toml +++ b/crates/gitbutler-core/Cargo.toml @@ -5,6 +5,7 @@ edition = "2021" authors = ["GitButler "] publish = false + [dev-dependencies] once_cell = "1.19" pretty_assertions = "1.4" diff --git a/crates/gitbutler-core/src/git/branch_ext.rs b/crates/gitbutler-core/src/git/branch_ext.rs new file mode 100644 index 0000000000..6bd578957a --- /dev/null +++ b/crates/gitbutler-core/src/git/branch_ext.rs @@ -0,0 +1,15 @@ +use anyhow::{Context, Result}; + +use crate::types::ReferenceName; + +pub trait BranchExt { + fn reference_name(&self) -> Result; +} + +impl<'repo> BranchExt for git2::Branch<'repo> { + fn reference_name(&self) -> Result { + let name = self.get().name().context("Failed to get branch name")?; + + Ok(name.into()) + } +} diff --git a/crates/gitbutler-core/src/git/commit_buffer.rs b/crates/gitbutler-core/src/git/commit_buffer.rs new file mode 100644 index 0000000000..f7fc11af12 --- /dev/null +++ b/crates/gitbutler-core/src/git/commit_buffer.rs @@ -0,0 +1,91 @@ +use bstr::{BStr, BString, ByteSlice, ByteVec}; +use core::str; + +use super::CommitHeadersV2; + +pub struct CommitBuffer { + heading: Vec<(BString, BString)>, + message: BString, +} + +impl CommitBuffer { + pub fn new(buffer: &[u8]) -> Self { + let buffer = BStr::new(buffer); + if let Some((heading, message)) = buffer.split_once_str("\n\n") { + let heading = heading + .lines() + .filter_map(|line| line.split_once_str(" ")) + .map(|(key, value)| (key.into(), value.into())) + .collect(); + + Self { + heading, + message: message.into(), + } + } else { + Self { + heading: vec![], + message: buffer.into(), + } + } + } + + pub fn set_header(&mut self, key: &str, value: &str) { + let mut set_heading = false; + self.heading.iter_mut().for_each(|(k, v)| { + if k == key { + *v = value.into(); + set_heading = true; + } + }); + + if !set_heading { + self.heading.push((key.into(), value.into())); + } + } + + /// Defers to the CommitHeadersV2 struct about which headers should be injected. + /// If `commit_headers: None` is provided, a default set of headers, including a generated change-id will be used + pub fn set_gitbutler_headers(&mut self, commit_headers: Option) { + if let Some(commit_headers) = commit_headers { + commit_headers.inject_into(self) + } else { + CommitHeadersV2::inject_default(self) + } + } + + pub fn as_bstring(&self) -> BString { + let mut output = BString::new(vec![]); + + for (key, value) in &self.heading { + output.push_str(key); + output.push_str(" "); + output.push_str(value); + output.push_str("\n"); + } + + output.push_str("\n"); + + output.push_str(&self.message); + + output + } +} + +impl From for CommitBuffer { + fn from(git2_buffer: git2::Buf) -> Self { + Self::new(&git2_buffer) + } +} + +impl From for CommitBuffer { + fn from(s: BString) -> Self { + Self::new(s.as_bytes()) + } +} + +impl From for BString { + fn from(buffer: CommitBuffer) -> BString { + buffer.as_bstring() + } +} diff --git a/crates/gitbutler-core/src/git/commit_ext.rs b/crates/gitbutler-core/src/git/commit_ext.rs index 6e506ef232..72ff418ea8 100644 --- a/crates/gitbutler-core/src/git/commit_ext.rs +++ b/crates/gitbutler-core/src/git/commit_ext.rs @@ -1,6 +1,7 @@ -// use anyhow::Result; use bstr::BStr; +use super::HasCommitHeaders; + /// Extension trait for `git2::Commit`. /// /// For now, it collects useful methods from `gitbutler-core::git::Commit` @@ -15,13 +16,9 @@ impl<'repo> CommitExt for git2::Commit<'repo> { fn message_bstr(&self) -> &BStr { self.message_bytes().as_ref() } + fn change_id(&self) -> Option { - let cid = self.header_field_bytes("change-id").ok()?; - if cid.is_empty() { - None - } else { - String::from_utf8(cid.to_owned()).ok() - } + self.gitbutler_headers().map(|headers| headers.change_id) } fn is_signed(&self) -> bool { self.header_field_bytes("gpgsig").is_ok() diff --git a/crates/gitbutler-core/src/git/commit_headers.rs b/crates/gitbutler-core/src/git/commit_headers.rs new file mode 100644 index 0000000000..d44612e1f5 --- /dev/null +++ b/crates/gitbutler-core/src/git/commit_headers.rs @@ -0,0 +1,132 @@ +use bstr::{BStr, BString}; +use uuid::Uuid; + +use super::CommitBuffer; + +/// Header used to determine which version of the headers is in use. This should never be changed +const HEADERS_VERSION_HEADER: &str = "gitbutler-headers-version"; + +const V1_CHANGE_ID_HEADER: &str = "change-id"; + +/// Used to represent the old commit headers layout. This should not be used in new code +#[derive(Debug)] +struct CommitHeadersV1 { + change_id: String, +} + +/// The version number used to represent the V2 headers +const V2_HEADERS_VERSION: &str = "2"; + +const V2_CHANGE_ID_HEADER: &str = "gitbutler-change-id"; +const V2_IS_UNAPPLIED_HEADER_COMMIT_HEADER: &str = "gitbutler-is-unapplied-header-commit"; +const V2_VBRANCH_NAME_HEADER: &str = "gitbutler-vbranch-name"; +#[derive(Debug)] +pub struct CommitHeadersV2 { + pub change_id: String, + pub is_unapplied_header_commit: bool, + pub vbranch_name: Option, +} + +impl Default for CommitHeadersV2 { + fn default() -> Self { + CommitHeadersV2 { + // Change ID using base16 encoding + change_id: Uuid::new_v4().to_string(), + is_unapplied_header_commit: false, + vbranch_name: None, + } + } +} + +impl From for CommitHeadersV2 { + fn from(commit_headers_v1: CommitHeadersV1) -> CommitHeadersV2 { + CommitHeadersV2 { + change_id: commit_headers_v1.change_id, + is_unapplied_header_commit: false, + vbranch_name: None, + } + } +} + +pub trait HasCommitHeaders { + fn gitbutler_headers(&self) -> Option; +} + +impl HasCommitHeaders for git2::Commit<'_> { + fn gitbutler_headers(&self) -> Option { + if let Ok(header) = self.header_field_bytes(HEADERS_VERSION_HEADER) { + let version_number = BString::new(header.to_owned()); + + // Parse v2 headers + if version_number == BStr::new(V2_HEADERS_VERSION) { + let change_id = self.header_field_bytes(V2_CHANGE_ID_HEADER).ok()?; + // We can safely assume that the change id should be UTF8 + let change_id = change_id.as_str()?.to_string(); + + // We can rationalize about is unapplied header commit with a bstring + let is_wip_commit = self + .header_field_bytes(V2_IS_UNAPPLIED_HEADER_COMMIT_HEADER) + .ok()?; + let is_wip_commit = BString::new(is_wip_commit.to_owned()); + + // We can safely assume that the vbranch name should be UTF8 + let vbranch_name = self + .header_field_bytes(V2_VBRANCH_NAME_HEADER) + .ok() + .and_then(|buffer| Some(buffer.as_str()?.to_string())); + + Some(CommitHeadersV2 { + change_id, + is_unapplied_header_commit: is_wip_commit == "true", + vbranch_name, + }) + } else { + // Must be for a version we don't recognise + None + } + } else { + // Parse v1 headers + let change_id = self.header_field_bytes(V1_CHANGE_ID_HEADER).ok()?; + // We can safely assume that the change id should be UTF8 + let change_id = change_id.as_str()?.to_string(); + + let headers = CommitHeadersV1 { change_id }; + + Some(headers.into()) + } + } +} + +impl CommitHeadersV2 { + /// Used to create a CommitHeadersV2. This does not allow a change_id to be + /// provided in order to ensure a consistent format. + pub fn new(is_unapplied_header_commit: bool, vbranch_name: Option) -> CommitHeadersV2 { + CommitHeadersV2 { + is_unapplied_header_commit, + vbranch_name, + ..Default::default() + } + } + + pub fn inject_default(commit_buffer: &mut CommitBuffer) { + CommitHeadersV2::default().inject_into(commit_buffer) + } + + pub fn inject_into(&self, commit_buffer: &mut CommitBuffer) { + commit_buffer.set_header(HEADERS_VERSION_HEADER, V2_HEADERS_VERSION); + commit_buffer.set_header(V2_CHANGE_ID_HEADER, &self.change_id); + let is_unapplied_header_commit = if self.is_unapplied_header_commit { + "true" + } else { + "false" + }; + commit_buffer.set_header( + V2_IS_UNAPPLIED_HEADER_COMMIT_HEADER, + is_unapplied_header_commit, + ); + + if let Some(vbranch_name) = &self.vbranch_name { + commit_buffer.set_header(V2_VBRANCH_NAME_HEADER, vbranch_name); + }; + } +} diff --git a/crates/gitbutler-core/src/git/mod.rs b/crates/gitbutler-core/src/git/mod.rs index c9e2fdcf6d..dacec8dc73 100644 --- a/crates/gitbutler-core/src/git/mod.rs +++ b/crates/gitbutler-core/src/git/mod.rs @@ -15,3 +15,12 @@ pub use tree_ext::*; mod commit_ext; pub use commit_ext::*; + +mod commit_buffer; +pub use commit_buffer::*; + +mod commit_headers; +pub use commit_headers::*; + +mod branch_ext; +pub use branch_ext::*; diff --git a/crates/gitbutler-core/src/git/repository_ext.rs b/crates/gitbutler-core/src/git/repository_ext.rs index c91c2c5ffb..8b7df64dbf 100644 --- a/crates/gitbutler-core/src/git/repository_ext.rs +++ b/crates/gitbutler-core/src/git/repository_ext.rs @@ -1,4 +1,5 @@ use anyhow::{anyhow, bail, Context, Result}; +use bstr::BString; use git2::{BlameOptions, Repository, Tree}; use std::{path::Path, process::Stdio, str}; use tracing::instrument; @@ -8,7 +9,7 @@ use crate::{ error::Code, }; -use super::Refname; +use super::{CommitBuffer, CommitHeadersV2, Refname}; use std::io::Write; #[cfg(unix)] use std::os::unix::fs::PermissionsExt; @@ -26,6 +27,13 @@ pub trait RepositoryExt { fn in_memory(&self, f: F) -> Result where F: FnOnce(&git2::Repository) -> Result; + /// Fetches the integration commit from the gitbutler/integration branch + fn integration_commit(&self) -> Result>; + /// Fetches the target commit by finding the parent of the integration commit + fn target_commit(&self) -> Result>; + /// Takes a CommitBuffer and returns it after being signed by by your git signing configuration + fn sign_buffer(&self, buffer: &CommitBuffer) -> Result; + fn checkout_index_builder<'a>(&'a self, index: &'a mut git2::Index) -> CheckoutIndexBuilder; fn checkout_index_path_builder>(&self, path: P) -> Result<()>; fn checkout_tree_builder<'a>(&'a self, tree: &'a git2::Tree<'a>) -> CheckoutTreeBuidler; @@ -40,8 +48,6 @@ pub trait RepositoryExt { /// This is for safety to assure the repository actually is in 'gitbutler mode'. fn integration_ref_from_head(&self) -> Result>; - fn target_commit(&self) -> Result>; - #[allow(clippy::too_many_arguments)] fn commit_with_signature( &self, @@ -51,7 +57,7 @@ pub trait RepositoryExt { message: &str, tree: &git2::Tree<'_>, parents: &[&git2::Commit<'_>], - change_id: Option<&str>, + commit_headers: Option, ) -> Result; fn blame( @@ -62,8 +68,6 @@ pub trait RepositoryExt { oldest_commit: git2::Oid, newest_commit: git2::Oid, ) -> Result; - - fn sign_buffer(&self, buffer: &str) -> Result; } impl RepositoryExt for Repository { @@ -141,10 +145,13 @@ impl RepositoryExt for Repository { } } - fn target_commit(&self) -> Result> { + fn integration_commit(&self) -> Result> { let integration_ref = self.integration_ref_from_head()?; - let integration_commit = integration_ref.peel_to_commit()?; - Ok(integration_commit.parent(0)?) + Ok(integration_ref.peel_to_commit()?) + } + + fn target_commit(&self) -> Result> { + Ok(self.integration_commit()?.parent(0)?) } #[allow(clippy::too_many_arguments)] @@ -156,17 +163,34 @@ impl RepositoryExt for Repository { message: &str, tree: &git2::Tree<'_>, parents: &[&git2::Commit<'_>], - change_id: Option<&str>, + commit_headers: Option, ) -> Result { - let commit_buffer = self.commit_create_buffer(author, committer, message, tree, parents)?; + fn commit_buffer( + repository: &git2::Repository, + commit_buffer: &CommitBuffer, + ) -> Result { + let oid = repository + .odb()? + .write(git2::ObjectType::Commit, &commit_buffer.as_bstring())?; + + Ok(oid) + } - let commit_buffer = inject_change_id(&commit_buffer, change_id)?; + let mut buffer: CommitBuffer = self + .commit_create_buffer(author, committer, message, tree, parents)? + .into(); + + buffer.set_gitbutler_headers(commit_headers); let oid = if self.gb_config()?.sign_commits.unwrap_or(false) { - let signature = sign_buffer(self, &commit_buffer); + let signature = self.sign_buffer(&buffer); match signature { Ok(signature) => self - .commit_signed(&commit_buffer, &signature, None) + .commit_signed( + buffer.as_bstring().to_string().as_str(), + signature.to_string().as_str(), + None, + ) .map_err(Into::into), Err(e) => { // If signing fails, set the "gitbutler.signCommits" config to false before erroring out @@ -178,9 +202,7 @@ impl RepositoryExt for Repository { } } } else { - self.odb()? - .write(git2::ObjectType::Commit, commit_buffer.as_bytes()) - .map_err(Into::into) + commit_buffer(self, &buffer) }?; // update reference if let Some(refname) = update_ref { @@ -206,140 +228,136 @@ impl RepositoryExt for Repository { self.blame_file(path, Some(&mut opts)) } - fn sign_buffer(&self, buffer: &str) -> Result { - sign_buffer(self, &buffer.to_string()) - } -} - -/// Signs the buffer with the configured gpg key, returning the signature. -pub fn sign_buffer(repo: &git2::Repository, buffer: &String) -> Result { - // check git config for gpg.signingkey - // TODO: support gpg.ssh.defaultKeyCommand to get the signing key if this value doesn't exist - let signing_key = repo.config()?.get_string("user.signingkey"); - if let Ok(signing_key) = signing_key { - let sign_format = repo.config()?.get_string("gpg.format"); - let is_ssh = if let Ok(sign_format) = sign_format { - sign_format == "ssh" - } else { - false - }; - - if is_ssh { - // write commit data to a temp file so we can sign it - let mut signature_storage = tempfile::NamedTempFile::new()?; - signature_storage.write_all(buffer.as_ref())?; - let buffer_file_to_sign_path = signature_storage.into_temp_path(); - - let gpg_program = repo.config()?.get_string("gpg.ssh.program"); - let mut gpg_program = gpg_program.unwrap_or("ssh-keygen".to_string()); - // if cmd is "", use gpg - if gpg_program.is_empty() { - gpg_program = "ssh-keygen".to_string(); - } - - let mut cmd = std::process::Command::new(gpg_program); - cmd.args(["-Y", "sign", "-n", "git", "-f"]); - - #[cfg(windows)] - cmd.creation_flags(0x08000000); // CREATE_NO_WINDOW - - let output; - // support literal ssh key - if let (true, signing_key) = is_literal_ssh_key(&signing_key) { - // write the key to a temp file - let mut key_storage = tempfile::NamedTempFile::new()?; - key_storage.write_all(signing_key.as_bytes())?; - - // if on unix - #[cfg(unix)] - { - // make sure the tempfile permissions are acceptable for a private ssh key - let mut permissions = key_storage.as_file().metadata()?.permissions(); - permissions.set_mode(0o600); - key_storage.as_file().set_permissions(permissions)?; + fn sign_buffer(&self, buffer: &CommitBuffer) -> Result { + // check git config for gpg.signingkey + // TODO: support gpg.ssh.defaultKeyCommand to get the signing key if this value doesn't exist + let signing_key = self.config()?.get_string("user.signingkey"); + if let Ok(signing_key) = signing_key { + let sign_format = self.config()?.get_string("gpg.format"); + let is_ssh = if let Ok(sign_format) = sign_format { + sign_format == "ssh" + } else { + false + }; + + if is_ssh { + // write commit data to a temp file so we can sign it + let mut signature_storage = tempfile::NamedTempFile::new()?; + signature_storage.write_all(&buffer.as_bstring())?; + let buffer_file_to_sign_path = signature_storage.into_temp_path(); + + let gpg_program = self.config()?.get_string("gpg.ssh.program"); + let mut gpg_program = gpg_program.unwrap_or("ssh-keygen".to_string()); + // if cmd is "", use gpg + if gpg_program.is_empty() { + gpg_program = "ssh-keygen".to_string(); } - let key_file_path = key_storage.into_temp_path(); - - cmd.arg(&key_file_path); - cmd.arg("-U"); - cmd.arg(&buffer_file_to_sign_path); - cmd.stderr(Stdio::piped()); - cmd.stdout(Stdio::piped()); - cmd.stdin(Stdio::null()); - - let child = cmd.spawn()?; - output = child.wait_with_output()?; - } else { - cmd.arg(signing_key); - cmd.arg(&buffer_file_to_sign_path); - cmd.stderr(Stdio::piped()); - cmd.stdout(Stdio::piped()); - cmd.stdin(Stdio::null()); - - let child = cmd.spawn()?; - output = child.wait_with_output()?; - } + let mut cmd = std::process::Command::new(gpg_program); + cmd.args(["-Y", "sign", "-n", "git", "-f"]); + + #[cfg(windows)] + cmd.creation_flags(0x08000000); // CREATE_NO_WINDOW + + let output; + // support literal ssh key + if let (true, signing_key) = is_literal_ssh_key(&signing_key) { + // write the key to a temp file + let mut key_storage = tempfile::NamedTempFile::new()?; + key_storage.write_all(signing_key.as_bytes())?; + + // if on unix + #[cfg(unix)] + { + // make sure the tempfile permissions are acceptable for a private ssh key + let mut permissions = key_storage.as_file().metadata()?.permissions(); + permissions.set_mode(0o600); + key_storage.as_file().set_permissions(permissions)?; + } + + let key_file_path = key_storage.into_temp_path(); + + cmd.arg(&key_file_path); + cmd.arg("-U"); + cmd.arg(&buffer_file_to_sign_path); + cmd.stderr(Stdio::piped()); + cmd.stdout(Stdio::piped()); + cmd.stdin(Stdio::null()); + + let child = cmd.spawn()?; + output = child.wait_with_output()?; + } else { + cmd.arg(signing_key); + cmd.arg(&buffer_file_to_sign_path); + cmd.stderr(Stdio::piped()); + cmd.stdout(Stdio::piped()); + cmd.stdin(Stdio::null()); + + let child = cmd.spawn()?; + output = child.wait_with_output()?; + } - if output.status.success() { - // read signed_storage path plus .sig - let signature_path = buffer_file_to_sign_path.with_extension("sig"); - let sig_data = std::fs::read(signature_path)?; - let signature = String::from_utf8_lossy(&sig_data).into_owned(); - return Ok(signature); + if output.status.success() { + // read signed_storage path plus .sig + let signature_path = buffer_file_to_sign_path.with_extension("sig"); + let sig_data = std::fs::read(signature_path)?; + let signature = BString::new(sig_data); + return Ok(signature); + } else { + let stderr = BString::new(output.stderr); + let stdout = BString::new(output.stdout); + let std_both = format!("{} {}", stdout, stderr); + bail!("Failed to sign SSH: {}", std_both); + } } else { - let stderr = String::from_utf8_lossy(&output.stderr); - let stdout = String::from_utf8_lossy(&output.stdout); - let std_both = format!("{} {}", stdout, stderr); - bail!("Failed to sign SSH: {}", std_both); - } - } else { - // is gpg - let gpg_program = repo.config()?.get_string("gpg.program"); - let mut gpg_program = gpg_program.unwrap_or("gpg".to_string()); - // if cmd is "", use gpg - if gpg_program.is_empty() { - gpg_program = "gpg".to_string(); - } + // is gpg + let gpg_program = self.config()?.get_string("gpg.program"); + let mut gpg_program = gpg_program.unwrap_or("gpg".to_string()); + // if cmd is "", use gpg + if gpg_program.is_empty() { + gpg_program = "gpg".to_string(); + } - let mut cmd = std::process::Command::new(gpg_program); - - cmd.args(["--status-fd=2", "-bsau", &signing_key]) - //.arg(&signed_storage) - .arg("-") - .stdout(Stdio::piped()) - .stderr(Stdio::piped()) - .stdin(Stdio::piped()); - - #[cfg(windows)] - cmd.creation_flags(0x08000000); // CREATE_NO_WINDOW - - let mut child = cmd - .spawn() - .context(anyhow::format_err!("failed to spawn {:?}", cmd))?; - child - .stdin - .take() - .expect("configured") - .write_all(buffer.to_string().as_ref())?; - - let output = child.wait_with_output()?; - if output.status.success() { - // read stdout - let signature = String::from_utf8_lossy(&output.stdout).into_owned(); - return Ok(signature); - } else { - let stderr = String::from_utf8_lossy(&output.stderr); - let stdout = String::from_utf8_lossy(&output.stdout); - let std_both = format!("{} {}", stdout, stderr); - bail!("Failed to sign GPG: {}", std_both); + let mut cmd = std::process::Command::new(gpg_program); + + cmd.args(["--status-fd=2", "-bsau", &signing_key]) + //.arg(&signed_storage) + .arg("-") + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .stdin(Stdio::piped()); + + #[cfg(windows)] + cmd.creation_flags(0x08000000); // CREATE_NO_WINDOW + + let mut child = cmd + .spawn() + .context(anyhow::format_err!("failed to spawn {:?}", cmd))?; + child + .stdin + .take() + .expect("configured") + .write_all(&buffer.as_bstring())?; + + let output = child.wait_with_output()?; + if output.status.success() { + // read stdout + let signature = BString::new(output.stdout); + return Ok(signature); + } else { + let stderr = BString::new(output.stderr); + let stdout = BString::new(output.stdout); + let std_both = format!("{} {}", stdout, stderr); + bail!("Failed to sign GPG: {}", std_both); + } } } + Err(anyhow::anyhow!("No signing key found")) } - Err(anyhow::anyhow!("No signing key found")) } -fn is_literal_ssh_key(string: &str) -> (bool, &str) { +/// Signs the buffer with the configured gpg key, returning the signature. +pub fn is_literal_ssh_key(string: &str) -> (bool, &str) { if let Some(key) = string.strip_prefix("key::") { return (true, key); } @@ -349,34 +367,6 @@ fn is_literal_ssh_key(string: &str) -> (bool, &str) { (false, string) } -// in commit_buffer, inject a line right before the first `\n\n` that we see: -// `change-id: ` -fn inject_change_id(commit_buffer: &[u8], change_id: Option<&str>) -> Result { - // if no change id, generate one - let change_id = change_id - .map(|id| id.to_string()) - .unwrap_or_else(|| format!("{}", uuid::Uuid::new_v4())); - - let commit_ends_in_newline = commit_buffer.ends_with(b"\n"); - let commit_buffer = str::from_utf8(commit_buffer)?; - let lines = commit_buffer.lines(); - let mut new_buffer = String::new(); - let mut found = false; - for line in lines { - if line.is_empty() && !found { - new_buffer.push_str(&format!("change-id {}\n", change_id)); - found = true; - } - new_buffer.push_str(line); - new_buffer.push('\n'); - } - if !commit_ends_in_newline { - // strip last \n - new_buffer.pop(); - } - Ok(new_buffer) -} - pub struct CheckoutTreeBuidler<'a> { repo: &'a git2::Repository, tree: &'a git2::Tree<'a>, diff --git a/crates/gitbutler-core/src/ops/snapshot.rs b/crates/gitbutler-core/src/ops/snapshot.rs index 17ce0eaffa..c8c04210ee 100644 --- a/crates/gitbutler-core/src/ops/snapshot.rs +++ b/crates/gitbutler-core/src/ops/snapshot.rs @@ -11,23 +11,12 @@ use super::entry::Trailer; /// Snapshot functionality impl Project { - pub(crate) fn snapshot_branch_applied( - &self, - snapshot_tree: git2::Oid, - result: Result<&String, &anyhow::Error>, - ) -> anyhow::Result<()> { - let result = result.map(|o| Some(o.clone())); - let details = SnapshotDetails::new(OperationKind::ApplyBranch) - .with_trailers(result_trailer(result, "name".to_string())); - self.commit_snapshot(snapshot_tree, details)?; - Ok(()) - } pub(crate) fn snapshot_branch_unapplied( &self, snapshot_tree: git2::Oid, - result: Result<&Option, &anyhow::Error>, + result: Result<&git2::Branch, &anyhow::Error>, ) -> anyhow::Result<()> { - let result = result.map(|o| o.clone().map(|b| b.name)); + let result = result.map(|o| o.name().ok().flatten().map(|s| s.to_string())); let details = SnapshotDetails::new(OperationKind::UnapplyBranch) .with_trailers(result_trailer(result, "name".to_string())); self.commit_snapshot(snapshot_tree, details)?; diff --git a/crates/gitbutler-core/src/project_repository/repository.rs b/crates/gitbutler-core/src/project_repository/repository.rs index 3e38271755..a85248f322 100644 --- a/crates/gitbutler-core/src/project_repository/repository.rs +++ b/crates/gitbutler-core/src/project_repository/repository.rs @@ -7,7 +7,6 @@ use std::{ use anyhow::{anyhow, Context, Result}; use super::conflicts; -use crate::error::Code; use crate::{ askpass, git::{self, Url}, @@ -15,6 +14,7 @@ use crate::{ ssh, users, virtual_branches::{Branch, BranchId}, }; +use crate::{error::Code, git::CommitHeadersV2}; use crate::{error::Marker, git::RepositoryExt}; pub struct Repository { @@ -339,12 +339,20 @@ impl Repository { message: &str, tree: &git2::Tree, parents: &[&git2::Commit], - change_id: Option<&str>, + commit_headers: Option, ) -> Result { let (author, committer) = super::signatures::signatures(self, user).context("failed to get signatures")?; self.repo() - .commit_with_signature(None, &author, &committer, message, tree, parents, change_id) + .commit_with_signature( + None, + &author, + &committer, + message, + tree, + parents, + commit_headers, + ) .context("failed to commit") } diff --git a/crates/gitbutler-core/src/projects/controller.rs b/crates/gitbutler-core/src/projects/controller.rs index 8f3b915b9e..c5420cb376 100644 --- a/crates/gitbutler-core/src/projects/controller.rs +++ b/crates/gitbutler-core/src/projects/controller.rs @@ -5,6 +5,7 @@ use std::{ use anyhow::{bail, Context, Result}; use async_trait::async_trait; +use bstr::BString; use super::{storage, storage::UpdateRequest, Project, ProjectId}; use crate::git::RepositoryExt; @@ -231,7 +232,7 @@ impl Controller { let project = self.projects_storage.get(id)?; let repo = project_repository::Repository::open(&project)?; - let signed = repo.repo().sign_buffer("test"); + let signed = repo.repo().sign_buffer(&BString::new("test".into()).into()); match signed { Ok(_) => Ok(true), Err(e) => Err(e), diff --git a/crates/gitbutler-core/src/rebase/mod.rs b/crates/gitbutler-core/src/rebase/mod.rs index abfc25563a..5f5fa5a755 100644 --- a/crates/gitbutler-core/src/rebase/mod.rs +++ b/crates/gitbutler-core/src/rebase/mod.rs @@ -1,3 +1,4 @@ +use crate::git::HasCommitHeaders; use crate::{error::Marker, git::CommitExt, git::RepositoryExt, project_repository}; use anyhow::{anyhow, Context, Result}; use bstr::ByteSlice; @@ -72,7 +73,7 @@ pub fn cherry_rebase_group( .find_tree(merge_tree_oid) .context("failed to find merge tree")?; - let change_id = to_rebase.change_id(); + let commit_headers = to_rebase.gitbutler_headers(); let commit_oid = project_repository .repo() @@ -83,7 +84,7 @@ pub fn cherry_rebase_group( &to_rebase.message_bstr().to_str_lossy(), &merge_tree, &[&head], - change_id.as_deref(), + commit_headers, ) .context("failed to create commit")?; diff --git a/crates/gitbutler-core/src/types/mod.rs b/crates/gitbutler-core/src/types/mod.rs index 4895cc90af..82113f4a34 100644 --- a/crates/gitbutler-core/src/types/mod.rs +++ b/crates/gitbutler-core/src/types/mod.rs @@ -8,3 +8,6 @@ pub mod default_true; pub struct Sensitive(pub T); mod sensitive; + +mod tagged_string; +pub use tagged_string::*; diff --git a/crates/gitbutler-core/src/types/tagged_string.rs b/crates/gitbutler-core/src/types/tagged_string.rs new file mode 100644 index 0000000000..a4f265a516 --- /dev/null +++ b/crates/gitbutler-core/src/types/tagged_string.rs @@ -0,0 +1,60 @@ +use std::{fmt, marker::PhantomData, ops::Deref}; + +use serde::{Deserialize, Deserializer, Serialize, Serializer}; + +/// Tagged string is designed to clarify the purpose of strings when used as a return type +pub struct TaggedString(String, PhantomData); + +impl From for TaggedString { + fn from(value: String) -> Self { + TaggedString(value, PhantomData) + } +} + +impl From<&str> for TaggedString { + fn from(value: &str) -> Self { + TaggedString(value.to_string(), PhantomData) + } +} + +impl Deref for TaggedString { + type Target = String; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +impl<'de, T> Deserialize<'de> for TaggedString { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + String::deserialize(deserializer).map(Into::into) + } +} + +impl Serialize for TaggedString { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + self.0.serialize(serializer) + } +} + +impl fmt::Display for TaggedString { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + self.0.fmt(f) + } +} + +impl fmt::Debug for TaggedString { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + self.0.fmt(f) + } +} + +pub struct _ReferenceName; +/// The name of a reference ie. `refs/heads/master` +pub type ReferenceName = TaggedString<_ReferenceName>; diff --git a/crates/gitbutler-core/src/virtual_branches/base.rs b/crates/gitbutler-core/src/virtual_branches/base.rs index cc426ba0b9..328b8b1b08 100644 --- a/crates/gitbutler-core/src/virtual_branches/base.rs +++ b/crates/gitbutler-core/src/virtual_branches/base.rs @@ -5,7 +5,7 @@ use git2::Index; use serde::Serialize; use super::{ - branch, + branch, convert_to_real_branch, integration::{ get_workspace_head, update_gitbutler_integration, GITBUTLER_INTEGRATION_REFERENCE, }, @@ -327,10 +327,10 @@ fn _print_tree(repo: &git2::Repository, tree: &git2::Tree) -> Result<()> { // determine if what the target branch is now pointing to is mergeable with our current working directory // merge the target branch into our current working directory // update the target sha -pub fn update_base_branch( - project_repository: &project_repository::Repository, +pub fn update_base_branch<'repo>( + project_repository: &'repo project_repository::Repository, user: Option<&users::User>, -) -> anyhow::Result> { +) -> anyhow::Result>> { project_repository.assure_resolved()?; // look up the target and see if there is a new oid @@ -346,10 +346,10 @@ pub fn update_base_branch( .peel_to_commit() .context(format!("failed to peel branch {} to commit", target.branch))?; - let mut unapplied_branches: Vec = Vec::new(); + let mut unapplied_branch_names: Vec = Vec::new(); if new_target_commit.id() == target.sha { - return Ok(unapplied_branches); + return Ok(unapplied_branch_names); } let new_target_tree = new_target_commit @@ -423,12 +423,15 @@ pub fn update_base_branch( if branch_tree_merge_index.has_conflicts() { // branch tree conflicts with new target, unapply branch for now. we'll handle it later, when user applies it back. - if branch.applied { - unapplied_branches.push(branch.clone()); - } - branch.applied = false; - vb_state.set_branch(branch.clone())?; - return Ok(Some(branch)); + let unapplied_real_branch = convert_to_real_branch( + project_repository, + branch.id, + Default::default(), + )?; + + unapplied_branch_names.push(unapplied_real_branch); + + return Ok(None); } let branch_merge_index_tree_oid = @@ -456,12 +459,14 @@ pub fn update_base_branch( if branch_head_merge_index.has_conflicts() { // branch commits conflict with new target, make sure the branch is // unapplied. conflicts witll be dealt with when applying it back. - if branch.applied { - unapplied_branches.push(branch.clone()); - } - branch.applied = false; - vb_state.set_branch(branch.clone())?; - return Ok(Some(branch)); + let unapplied_real_branch = convert_to_real_branch( + project_repository, + branch.id, + Default::default(), + )?; + unapplied_branch_names.push(unapplied_real_branch); + + return Ok(None); } // branch commits do not conflict with new target, so lets merge them @@ -567,7 +572,7 @@ pub fn update_base_branch( // Rewriting the integration commit is necessary after changing target sha. super::integration::update_gitbutler_integration(&vb_state, project_repository)?; - Ok(unapplied_branches) + Ok(unapplied_branch_names) } pub fn target_to_base_branch( diff --git a/crates/gitbutler-core/src/virtual_branches/controller.rs b/crates/gitbutler-core/src/virtual_branches/controller.rs index d2418ae95a..c5add59084 100644 --- a/crates/gitbutler-core/src/virtual_branches/controller.rs +++ b/crates/gitbutler-core/src/virtual_branches/controller.rs @@ -1,4 +1,8 @@ -use crate::ops::entry::{OperationKind, SnapshotDetails}; +use crate::{ + git::BranchExt, + ops::entry::{OperationKind, SnapshotDetails}, + types::ReferenceName, +}; use anyhow::Result; use std::{collections::HashMap, path::Path, sync::Arc}; @@ -7,7 +11,8 @@ use tokio::{sync::Semaphore, task::JoinHandle}; use super::{ branch::{BranchId, BranchOwnershipClaims}, - target, target_to_base_branch, BaseBranch, Branch, RemoteBranchFile, VirtualBranchesHandle, + target, target_to_base_branch, BaseBranch, NameConflitResolution, RemoteBranchFile, + VirtualBranchesHandle, }; use crate::{ git, project_repository, @@ -72,16 +77,6 @@ impl Controller { .can_apply_remote_branch(project_id, branch_name) } - pub async fn can_apply_virtual_branch( - &self, - project_id: ProjectId, - branch_id: BranchId, - ) -> Result { - self.inner(project_id) - .await - .can_apply_virtual_branch(project_id, branch_id) - } - pub async fn list_virtual_branches( &self, project_id: ProjectId, @@ -161,7 +156,7 @@ impl Controller { .await } - pub async fn update_base_branch(&self, project_id: ProjectId) -> Result> { + pub async fn update_base_branch(&self, project_id: ProjectId) -> Result> { self.inner(project_id) .await .update_base_branch(project_id) @@ -189,17 +184,6 @@ impl Controller { .await } - pub async fn apply_virtual_branch( - &self, - project_id: ProjectId, - branch_id: BranchId, - ) -> Result<()> { - self.inner(project_id) - .await - .apply_virtual_branch(project_id, branch_id) - .await - } - pub async fn unapply_ownership( &self, project_id: ProjectId, @@ -301,14 +285,15 @@ impl Controller { .await } - pub async fn unapply_virtual_branch( + pub async fn convert_to_real_branch( &self, project_id: ProjectId, branch_id: BranchId, - ) -> Result<()> { + name_conflict_resolution: NameConflitResolution, + ) -> Result { self.inner(project_id) .await - .unapply_virtual_branch(project_id, branch_id) + .convert_to_real_branch(project_id, branch_id, name_conflict_resolution) .await } @@ -325,18 +310,6 @@ impl Controller { .await } - pub async fn cherry_pick( - &self, - project_id: ProjectId, - branch_id: BranchId, - commit_oid: git2::Oid, - ) -> Result> { - self.inner(project_id) - .await - .cherry_pick(project_id, branch_id, commit_oid) - .await - } - pub async fn list_remote_branches( &self, project_id: ProjectId, @@ -471,16 +444,6 @@ impl ControllerInner { super::is_remote_branch_mergeable(&project_repository, branch_name).map_err(Into::into) } - pub fn can_apply_virtual_branch( - &self, - project_id: ProjectId, - branch_id: BranchId, - ) -> Result { - let project = self.projects.get(project_id)?; - let project_repository = project_repository::Repository::open(&project)?; - super::is_virtual_branch_mergeable(&project_repository, branch_id).map_err(Into::into) - } - pub async fn list_virtual_branches( &self, project_id: ProjectId, @@ -569,14 +532,21 @@ impl ControllerInner { }) } - pub async fn update_base_branch(&self, project_id: ProjectId) -> Result> { + pub async fn update_base_branch(&self, project_id: ProjectId) -> Result> { let _permit = self.semaphore.acquire().await; self.with_verify_branch(project_id, |project_repository, user| { let _ = project_repository .project() .create_snapshot(SnapshotDetails::new(OperationKind::UpdateWorkspaceBase)); - super::update_base_branch(project_repository, user).map_err(Into::into) + super::update_base_branch(project_repository, user) + .map(|unapplied_branches| { + unapplied_branches + .iter() + .filter_map(|unapplied_branch| unapplied_branch.reference_name().ok()) + .collect() + }) + .map_err(Into::into) }) } @@ -620,27 +590,6 @@ impl ControllerInner { }) } - pub async fn apply_virtual_branch( - &self, - project_id: ProjectId, - branch_id: BranchId, - ) -> Result<()> { - let _permit = self.semaphore.acquire().await; - - self.with_verify_branch(project_id, |project_repository, user| { - let snapshot_tree = project_repository.project().prepare_snapshot(); - let result = - super::apply_branch(project_repository, branch_id, user).map_err(Into::into); - - let _ = snapshot_tree.and_then(|snapshot_tree| { - project_repository - .project() - .snapshot_branch_applied(snapshot_tree, result.as_ref()) - }); - result.map(|_| ()) - }) - } - pub async fn unapply_ownership( &self, project_id: ProjectId, @@ -785,22 +734,28 @@ impl ControllerInner { }) } - pub async fn unapply_virtual_branch( + pub async fn convert_to_real_branch( &self, project_id: ProjectId, branch_id: BranchId, - ) -> Result<()> { + name_conflict_resolution: NameConflitResolution, + ) -> Result { let _permit = self.semaphore.acquire().await; self.with_verify_branch(project_id, |project_repository, _| { let snapshot_tree = project_repository.project().prepare_snapshot(); - let result = super::unapply_branch(project_repository, branch_id).map_err(Into::into); + let result = super::convert_to_real_branch( + project_repository, + branch_id, + name_conflict_resolution, + ) + .map_err(Into::into); let _ = snapshot_tree.and_then(|snapshot_tree| { project_repository .project() .snapshot_branch_unapplied(snapshot_tree, result.as_ref()) }); - result.map(|_| ()) + result.and_then(|b| b.reference_name()) }) } @@ -819,22 +774,6 @@ impl ControllerInner { .await? } - pub async fn cherry_pick( - &self, - project_id: ProjectId, - branch_id: BranchId, - commit_oid: git2::Oid, - ) -> Result> { - let _permit = self.semaphore.acquire().await; - - self.with_verify_branch(project_id, |project_repository, _| { - let _ = project_repository - .project() - .create_snapshot(SnapshotDetails::new(OperationKind::CherryPick)); - super::cherry_pick(project_repository, branch_id, commit_oid).map_err(Into::into) - }) - } - pub fn list_remote_branches(&self, project_id: ProjectId) -> Result> { let project = self.projects.get(project_id)?; let project_repository = project_repository::Repository::open(&project)?; diff --git a/crates/gitbutler-core/src/virtual_branches/integration.rs b/crates/gitbutler-core/src/virtual_branches/integration.rs index 6d586effad..ca46232627 100644 --- a/crates/gitbutler-core/src/virtual_branches/integration.rs +++ b/crates/gitbutler-core/src/virtual_branches/integration.rs @@ -22,7 +22,7 @@ const WORKSPACE_HEAD: &str = "Workspace Head"; pub const GITBUTLER_INTEGRATION_COMMIT_AUTHOR_NAME: &str = "GitButler"; pub const GITBUTLER_INTEGRATION_COMMIT_AUTHOR_EMAIL: &str = "gitbutler@gitbutler.com"; -fn get_committer<'a>() -> Result> { +pub fn get_integration_commiter<'a>() -> Result> { Ok(git2::Signature::now( GITBUTLER_INTEGRATION_COMMIT_AUTHOR_NAME, GITBUTLER_INTEGRATION_COMMIT_AUTHOR_EMAIL, @@ -85,7 +85,7 @@ pub fn get_workspace_head( } // TODO(mg): Can we make this a constant? - let committer = get_committer()?; + let committer = get_integration_commiter()?; let mut heads: Vec> = applied_branches .iter() @@ -228,7 +228,7 @@ pub fn update_gitbutler_integration( message.push_str("For more information about what we're doing here, check out our docs:\n"); message.push_str("https://docs.gitbutler.com/features/virtual-branches/integration-branch\n"); - let committer = get_committer()?; + let committer = get_integration_commiter()?; // It would be nice if we could pass an `update_ref` parameter to this function, but that // requires committing to the tip of the branch, and we're mostly replacing the tip. diff --git a/crates/gitbutler-core/src/virtual_branches/state.rs b/crates/gitbutler-core/src/virtual_branches/state.rs index c4be0cf265..f107aee135 100644 --- a/crates/gitbutler-core/src/virtual_branches/state.rs +++ b/crates/gitbutler-core/src/virtual_branches/state.rs @@ -5,6 +5,7 @@ use std::{ use crate::{error::Code, fs::read_toml_file_or_default}; use anyhow::{anyhow, Result}; +use itertools::Itertools; use serde::{Deserialize, Serialize}; use super::{target::Target, Branch}; @@ -126,6 +127,38 @@ impl VirtualBranchesHandle { fn write_file(&self, virtual_branches: &VirtualBranches) -> Result<()> { write(self.file_path.as_path(), virtual_branches) } + + pub fn update_ordering(&self) -> Result<()> { + let succeeded = self + .list_branches()? + .iter() + .sorted_by_key(|branch| branch.order) + .enumerate() + .all(|(index, branch)| { + let mut branch = branch.clone(); + branch.order = index; + self.set_branch(branch).is_ok() + }); + + if succeeded { + Ok(()) + } else { + Err(anyhow!("Failed to update virtual branches ordering")) + } + } + + pub fn next_order_index(&self) -> Result { + self.update_ordering()?; + let order = self + .list_branches()? + .iter() + .sorted_by_key(|branch| branch.order) + .collect::>() + .last() + .map_or(0, |b| b.order + 1); + + Ok(order) + } } fn write>(file_path: P, virtual_branches: &VirtualBranches) -> Result<()> { diff --git a/crates/gitbutler-core/src/virtual_branches/virtual.rs b/crates/gitbutler-core/src/virtual_branches/virtual.rs index ec811392cd..59759db983 100644 --- a/crates/gitbutler-core/src/virtual_branches/virtual.rs +++ b/crates/gitbutler-core/src/virtual_branches/virtual.rs @@ -12,13 +12,14 @@ use std::{ use anyhow::{anyhow, bail, Context, Result}; use bstr::{BString, ByteSlice, ByteVec}; use diffy::{apply_bytes as diffy_apply, Line, Patch}; +use git2::build::TreeUpdateBuilder; use git2::ErrorCode; use git2_hooks::HookResult; use hex::ToHex; use regex::Regex; -use serde::Serialize; +use serde::{Deserialize, Serialize}; -use super::integration::get_workspace_head; +use super::integration::{get_integration_commiter, get_workspace_head}; use super::{ branch::{ self, Branch, BranchCreateRequest, BranchId, BranchOwnershipClaims, Hunk, OwnershipClaim, @@ -29,7 +30,7 @@ use crate::error::Code; use crate::error::Marker; use crate::git::diff::GitHunk; use crate::git::diff::{diff_files_into_hunks, trees, FileDiff}; -use crate::git::{CommitExt, RepositoryExt}; +use crate::git::{CommitExt, CommitHeadersV2, HasCommitHeaders, RepositoryExt}; use crate::rebase::{cherry_rebase, cherry_rebase_group}; use crate::time::now_since_unix_epoch_ms; use crate::virtual_branches::branch::HunkHash; @@ -221,229 +222,20 @@ impl From> for Author { } } +#[derive(Default, Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", tag = "type", content = "value")] +pub enum NameConflitResolution { + #[default] + Suffix, + Rename(String), + Overwrite, +} + pub fn normalize_branch_name(name: &str) -> String { let pattern = Regex::new("[^A-Za-z0-9_/.#]+").unwrap(); pattern.replace_all(name, "-").to_string() } -pub fn apply_branch( - project_repository: &project_repository::Repository, - branch_id: BranchId, - user: Option<&users::User>, -) -> Result { - project_repository.assure_resolved()?; - let repo = project_repository.repo(); - - let vb_state = project_repository.project().virtual_branches(); - let default_target = vb_state.get_default_target()?; - - let mut branch = vb_state.get_branch(branch_id)?; - - if branch.applied { - return Ok(branch.name); - } - - let target_commit = repo - .find_commit(default_target.sha) - .context("failed to find target commit")?; - let target_tree = target_commit.tree().context("failed to get target tree")?; - - // calculate the merge base and make sure it's the same as the target commit - // if not, we need to merge or rebase the branch to get it up to date - - let merge_base = repo - .merge_base(default_target.sha, branch.head) - .context(format!( - "failed to find merge base between {} and {}", - default_target.sha, branch.head - ))?; - if merge_base != default_target.sha { - // Branch is out of date, merge or rebase it - let merge_base_tree = repo - .find_commit(merge_base) - .context(format!("failed to find merge base commit {}", merge_base))? - .tree() - .context("failed to find merge base tree")?; - - let branch_tree = repo - .find_tree(branch.tree) - .context("failed to find branch tree")?; - - let mut merge_index = repo - .merge_trees(&merge_base_tree, &branch_tree, &target_tree, None) - .context("failed to merge trees")?; - - if merge_index.has_conflicts() { - // currently we can only deal with the merge problem branch - for mut branch in get_status_by_branch(project_repository, Some(&target_commit.id()))? - .0 - .into_iter() - .map(|(branch, _)| branch) - .filter(|branch| branch.applied) - { - branch.applied = false; - vb_state.set_branch(branch)?; - } - - // apply the branch - branch.applied = true; - vb_state.set_branch(branch.clone())?; - - // checkout the conflicts - repo.checkout_index_builder(&mut merge_index) - .allow_conflicts() - .conflict_style_merge() - .force() - .checkout() - .context("failed to checkout index")?; - - // mark conflicts - let conflicts = merge_index - .conflicts() - .context("failed to get merge index conflicts")?; - let mut merge_conflicts = Vec::new(); - for path in conflicts.flatten() { - if let Some(ours) = path.our { - let path = std::str::from_utf8(&ours.path) - .context("failed to convert path to utf8")? - .to_string(); - merge_conflicts.push(path); - } - } - conflicts::mark( - project_repository, - &merge_conflicts, - Some(default_target.sha), - )?; - - return Ok(branch.name); - } - - let head_commit = repo - .find_commit(branch.head) - .context("failed to find head commit")?; - - let merged_branch_tree_oid = merge_index - .write_tree_to(project_repository.repo()) - .context("failed to write tree")?; - - let merged_branch_tree = repo - .find_tree(merged_branch_tree_oid) - .context("failed to find tree")?; - - let ok_with_force_push = branch.allow_rebasing; - if branch.upstream.is_some() && !ok_with_force_push { - // branch was pushed to upstream, and user doesn't like force pushing. - // create a merge commit to avoid the need of force pushing then. - - let new_branch_head = project_repository.commit( - user, - format!( - "Merged {}/{} into {}", - default_target.branch.remote(), - default_target.branch.branch(), - branch.name - ) - .as_str(), - &merged_branch_tree, - &[&head_commit, &target_commit], - None, - )?; - - // ok, update the virtual branch - branch.head = new_branch_head; - } else { - let rebase = cherry_rebase( - project_repository, - target_commit.id(), - target_commit.id(), - branch.head, - ); - let mut rebase_success = true; - let mut last_rebase_head = branch.head; - match rebase { - Ok(rebase_oid) => { - if let Some(oid) = rebase_oid { - last_rebase_head = oid; - } - } - Err(_) => { - rebase_success = false; - } - } - - if rebase_success { - // rebase worked out, rewrite the branch head - branch.head = last_rebase_head; - } else { - // rebase failed, do a merge commit - - // get tree from merge_tree_oid - let merge_tree = repo - .find_tree(merged_branch_tree_oid) - .context("failed to find tree")?; - - // commit the merge tree oid - let new_branch_head = project_repository - .commit( - user, - format!( - "Merged {}/{} into {}", - default_target.branch.remote(), - default_target.branch.branch(), - branch.name - ) - .as_str(), - &merge_tree, - &[&head_commit, &target_commit], - None, - ) - .context("failed to commit merge")?; - - branch.head = new_branch_head; - } - } - - branch.tree = repo - .find_commit(branch.head)? - .tree() - .map_err(anyhow::Error::from)? - .id(); - vb_state.set_branch(branch.clone())?; - } - - let wd_tree = project_repository.repo().get_wd_tree()?; - - let branch_tree = repo - .find_tree(branch.tree) - .context("failed to find branch tree")?; - - // check index for conflicts - let mut merge_index = repo - .merge_trees(&target_tree, &wd_tree, &branch_tree, None) - .context("failed to merge trees")?; - - if merge_index.has_conflicts() { - return Err(anyhow!("branch {branch_id} is in a conflicting state")) - .context(Marker::ProjectConflict); - } - - // apply the branch - branch.applied = true; - vb_state.set_branch(branch.clone())?; - - ensure_selected_for_changes(&vb_state).context("failed to ensure selected for changes")?; - - // checkout the merge index - repo.checkout_index_builder(&mut merge_index) - .force() - .checkout() - .context("failed to checkout index")?; - - super::integration::update_gitbutler_integration(&vb_state, project_repository)?; - Ok(branch.name) -} - pub fn unapply_ownership( project_repository: &project_repository::Repository, ownership: &BranchOwnershipClaims, @@ -574,119 +366,124 @@ pub fn reset_files( } // to unapply a branch, we need to write the current tree out, then remove those file changes from the wd -pub fn unapply_branch( +pub fn convert_to_real_branch( project_repository: &project_repository::Repository, branch_id: BranchId, -) -> Result> { - let vb_state = project_repository.project().virtual_branches(); + name_conflict_resolution: NameConflitResolution, +) -> Result> { + fn build_real_branch<'l>( + project_repository: &'l project_repository::Repository, + vbranch: &branch::Branch, + name_conflict_resolution: NameConflitResolution, + ) -> Result> { + let repo = project_repository.repo(); + let target_commit = repo.find_commit(vbranch.head)?; + let branch_name = vbranch.name.clone(); + let branch_name = normalize_branch_name(&branch_name); + + // Is there a name conflict? + let branch_name = if repo + .find_branch(branch_name.as_str(), git2::BranchType::Local) + .is_ok() + { + match name_conflict_resolution { + NameConflitResolution::Suffix => { + let mut suffix = 1; + loop { + let new_branch_name = format!("{}-{}", branch_name, suffix); + if repo + .find_branch(new_branch_name.as_str(), git2::BranchType::Local) + .is_err() + { + break new_branch_name; + } + suffix += 1; + } + } + NameConflitResolution::Rename(new_name) => { + if repo + .find_branch(new_name.as_str(), git2::BranchType::Local) + .is_ok() + { + Err(anyhow!("Branch with name {} already exists", new_name))? + } else { + new_name + } + } + NameConflitResolution::Overwrite => branch_name, + } + } else { + branch_name + }; + + let branch = repo.branch(&branch_name, &target_commit, true)?; - let mut target_branch = vb_state.get_branch(branch_id)?; - if !target_branch.applied { - return Ok(Some(target_branch)); + build_metadata_commit(project_repository, vbranch, &branch)?; + + Ok(branch) } + fn build_metadata_commit<'l>( + project_repository: &'l project_repository::Repository, + vbranch: &branch::Branch, + branch: &git2::Branch<'l>, + ) -> Result { + let repo = project_repository.repo(); - let default_target = vb_state.get_default_target()?; - let repo = project_repository.repo(); - let target_commit = repo - .find_commit(default_target.sha) - .context("failed to find target commit")?; + // Build wip tree as either any uncommitted changes or an empty tree + let vbranch_wip_tree = repo.find_tree(vbranch.tree)?; + let vbranch_head_tree = repo.find_commit(vbranch.head)?.tree()?; - let final_tree = if conflicts::is_resolving(project_repository) { - { - target_branch.applied = false; - target_branch.selected_for_changes = None; - vb_state.set_branch(target_branch.clone())?; - } - conflicts::clear(project_repository).context("failed to clear conflicts")?; - target_commit.tree().context("failed to get target tree")? - } else { - // if we are not resolving, we need to merge the rest of the applied branches - let applied_branches = vb_state - .list_branches() - .context("failed to read virtual branches")? - .into_iter() - .filter(|b| b.applied) - .collect::>(); + let tree = if vbranch_head_tree.id() != vbranch_wip_tree.id() { + vbranch_wip_tree + } else { + repo.find_tree(TreeUpdateBuilder::new().create_updated(repo, &vbranch_head_tree)?)? + }; - let integration_commit = - super::integration::update_gitbutler_integration(&vb_state, project_repository)?; + // Build commit message + let mut message = "GitButler WIP Commit".to_string(); + message.push_str("\n\n"); - let (applied_statuses, _) = get_applied_status( - project_repository, - &integration_commit, - &default_target.sha, - applied_branches, - ) - .context("failed to get status by branch")?; + // Commit wip commit + let committer = get_integration_commiter()?; + let parent = branch.get().peel_to_commit()?; - let status = applied_statuses - .iter() - .find(|(s, _)| s.id == target_branch.id) - .context("failed to find status for branch"); - - if let Ok((branch, files)) = status { - update_conflict_markers(project_repository, files)?; - if files.is_empty() && branch.head == default_target.sha { - // if there is nothing to unapply, remove the branch straight away - vb_state - .remove_branch(target_branch.id) - .context("Failed to remove branch")?; - - ensure_selected_for_changes(&vb_state) - .context("failed to ensure selected for changes")?; - - project_repository.delete_branch_reference(&target_branch)?; - return Ok(None); - } + let commit_headers = CommitHeadersV2::new(true, Some(vbranch.name.clone())); - target_branch.tree = write_tree(project_repository, &target_branch.head, files)?; - target_branch.applied = false; - target_branch.selected_for_changes = None; - vb_state.set_branch(target_branch.clone())?; - } + let commit_oid = repo.commit_with_signature( + Some(&branch.try_into()?), + &committer, + &committer, + &message, + &tree, + &[&parent], + Some(commit_headers), + )?; - let target_commit = repo - .find_commit(default_target.sha) - .context("failed to find target commit")?; + Ok(commit_oid) + } + let vb_state = project_repository.project().virtual_branches(); - // ok, update the wd with the union of the rest of the branches - let base_tree = target_commit.tree().context("failed to get target tree")?; + let target_branch = vb_state.get_branch(branch_id)?; - // go through the other applied branches and merge them into the final tree - // then check that out into the working directory - let final_tree = applied_statuses - .into_iter() - .filter(|(branch, _)| branch.id != branch_id) - .fold( - target_commit.tree().context("failed to get target tree"), - |final_tree, status| { - let final_tree = final_tree?; - let branch = status.0; - let tree_oid = write_tree(project_repository, &branch.head, status.1)?; - let branch_tree = repo.find_tree(tree_oid)?; - let mut result = - repo.merge_trees(&base_tree, &final_tree, &branch_tree, None)?; - let final_tree_oid = result.write_tree_to(project_repository.repo())?; - repo.find_tree(final_tree_oid) - .context("failed to find tree") - }, - )?; + // Convert the vbranch to a real branch + let real_branch = + build_real_branch(project_repository, &target_branch, name_conflict_resolution)?; - ensure_selected_for_changes(&vb_state).context("failed to ensure selected for changes")?; + delete_branch(project_repository, branch_id)?; - final_tree - }; + // If we were conflicting, it means that it was the only branch applied. Since we've now unapplied it we can clear all conflicts + if conflicts::is_conflicting(project_repository, None)? { + conflicts::clear(project_repository)?; + } - // checkout final_tree into the working directory - repo.checkout_tree_builder(&final_tree) - .force() - .remove_untracked() - .checkout() - .context("failed to checkout tree")?; + vb_state.update_ordering()?; + + // Ensure we still have a default target + ensure_selected_for_changes(&vb_state).context("failed to ensure selected for changes")?; super::integration::update_gitbutler_integration(&vb_state, project_repository)?; - Ok(Some(target_branch)) + Ok(real_branch) } fn find_base_tree<'a>( @@ -734,6 +531,10 @@ pub fn list_virtual_branches( .unwrap_or(-1); for (branch, files) in statuses { + if !branch.applied { + convert_to_real_branch(project_repository, branch.id, Default::default())?; + } + let repo = project_repository.repo(); update_conflict_markers(project_repository, &files)?; @@ -1011,10 +812,7 @@ pub fn create_virtual_branch( all_virtual_branches.sort_by_key(|branch| branch.order); - let order = create - .order - .unwrap_or(all_virtual_branches.len()) - .clamp(0, all_virtual_branches.len()); + let order = create.order.unwrap_or(vb_state.next_order_index()?); let selected_for_changes = if let Some(selected_for_changes) = create.selected_for_changes { if selected_for_changes { @@ -1391,9 +1189,52 @@ pub fn delete_branch( .project() .snapshot_branch_deletion(branch.name.clone()); - if branch.applied && unapply_branch(project_repository, branch_id)?.is_none() { - return Ok(()); - } + let repo = project_repository.repo(); + + let integration_commit = repo.integration_commit()?; + let target_commit = repo.target_commit()?; + let base_tree = target_commit.tree().context("failed to get target tree")?; + + let applied_branches = vb_state + .list_branches() + .context("failed to read virtual branches")? + .into_iter() + .filter(|b| b.applied) + .collect::>(); + + let (applied_statuses, _) = get_applied_status( + project_repository, + &integration_commit.id(), + &target_commit.id(), + applied_branches, + ) + .context("failed to get status by branch")?; + + // go through the other applied branches and merge them into the final tree + // then check that out into the working directory + let final_tree = applied_statuses + .into_iter() + .filter(|(branch, _)| branch.id != branch_id) + .fold( + target_commit.tree().context("failed to get target tree"), + |final_tree, status| { + let final_tree = final_tree?; + let branch = status.0; + let tree_oid = write_tree(project_repository, &branch.head, status.1)?; + let branch_tree = repo.find_tree(tree_oid)?; + let mut result = repo.merge_trees(&base_tree, &final_tree, &branch_tree, None)?; + let final_tree_oid = result.write_tree_to(repo)?; + repo.find_tree(final_tree_oid) + .context("failed to find tree") + }, + )?; + + // checkout final_tree into the working directory + repo.checkout_tree_builder(&final_tree) + .force() + .remove_untracked() + .checkout() + .context("failed to checkout tree")?; vb_state .remove_branch(branch.id) @@ -2468,11 +2309,6 @@ fn is_commit_integrated( return Ok(true); } - // if it's an empty commit we can't base integration status on merge trees. - if commit.parent_count() == 1 && commit.parent(0)?.tree_id() == commit.tree_id() { - return Ok(false); - } - // try to merge our tree into the upstream tree let mut merge_index = project_repository .repo() @@ -2528,56 +2364,6 @@ pub fn is_remote_branch_mergeable( Ok(mergeable) } -pub fn is_virtual_branch_mergeable( - project_repository: &project_repository::Repository, - branch_id: BranchId, -) -> Result { - let vb_state = project_repository.project().virtual_branches(); - let branch = vb_state.get_branch(branch_id)?; - if branch.applied { - return Ok(true); - } - - let default_target = vb_state.get_default_target()?; - // determine if this branch is up to date with the target/base - let merge_base = project_repository - .repo() - .merge_base(default_target.sha, branch.head) - .context("failed to find merge base")?; - - if merge_base != default_target.sha { - return Ok(false); - } - - let branch_commit = project_repository - .repo() - .find_commit(branch.head) - .context("failed to find branch commit")?; - - let target_commit = project_repository - .repo() - .find_commit(default_target.sha) - .context("failed to find target commit")?; - - let base_tree = find_base_tree(project_repository.repo(), &branch_commit, &target_commit)?; - - let wd_tree = project_repository.repo().get_wd_tree()?; - - // determine if this tree is mergeable - let branch_tree = project_repository - .repo() - .find_tree(branch.tree) - .context("failed to find branch tree")?; - - let is_mergeable = !project_repository - .repo() - .merge_trees(&base_tree, &branch_tree, &wd_tree, None) - .context("failed to merge trees")? - .has_conflicts(); - - Ok(is_mergeable) -} - // this function takes a list of file ownership from a "from" commit and "moves" // those changes to a "to" commit in a branch. This allows users to drag changes // from one commit to another. @@ -2710,7 +2496,6 @@ pub fn move_commit_file( let new_from_tree = &repo .find_tree(new_from_tree_id) .with_context(|| "tree {new_from_tree_oid} not found")?; - let change_id = from_commit.change_id(); let new_from_commit_oid = project_repository .repo() .commit_with_signature( @@ -2720,7 +2505,7 @@ pub fn move_commit_file( &from_commit.message_bstr().to_str_lossy(), new_from_tree, &[&from_parent], - change_id.as_deref(), + from_commit.gitbutler_headers(), ) .context("commit failed")?; @@ -2786,7 +2571,6 @@ pub fn move_commit_file( .find_tree(new_tree_oid) .context("failed to find new tree")?; let parents: Vec<_> = amend_commit.parents().collect(); - let change_id = amend_commit.change_id(); let commit_oid = project_repository .repo() .commit_with_signature( @@ -2796,7 +2580,7 @@ pub fn move_commit_file( &amend_commit.message_bstr().to_str_lossy(), &new_tree, &parents.iter().collect::>(), - change_id.as_deref(), + amend_commit.gitbutler_headers(), ) .context("failed to create commit")?; @@ -2945,7 +2729,7 @@ pub fn amend( &amend_commit.message_bstr().to_str_lossy(), &new_tree, &parents.iter().collect::>(), - amend_commit.change_id().as_deref(), + amend_commit.gitbutler_headers(), ) .context("failed to create commit")?; @@ -3181,184 +2965,6 @@ pub fn undo_commit( Ok(()) } -pub fn cherry_pick( - project_repository: &project_repository::Repository, - branch_id: BranchId, - target_commit_id: git2::Oid, -) -> Result> { - project_repository.assure_unconflicted()?; - - let vb_state = project_repository.project().virtual_branches(); - - let mut branch = vb_state - .get_branch(branch_id) - .context("failed to read branch")?; - - if !branch.applied { - // todo? - bail!("can not cherry pick a branch that is not applied") - } - - let target_commit = project_repository - .repo() - .find_commit(target_commit_id) - .map_err(|err| match err { - err if err.code() == git2::ErrorCode::NotFound => { - anyhow!("commit {target_commit_id} not found ") - } - err => err.into(), - })?; - - let branch_head_commit = project_repository - .repo() - .find_commit(branch.head) - .context("failed to find branch tree")?; - - let default_target = vb_state.get_default_target()?; - - // if any other branches are applied, unapply them - let applied_branches = vb_state - .list_branches() - .context("failed to read virtual branches")? - .into_iter() - .filter(|b| b.applied) - .collect::>(); - - let integration_commit_id = get_workspace_head(&vb_state, project_repository)?; - - let (applied_statuses, _) = get_applied_status( - project_repository, - &integration_commit_id, - &default_target.sha, - applied_branches, - )?; - - let branch_files = applied_statuses - .iter() - .find(|(b, _)| b.id == branch_id) - .map(|(_, f)| f) - .context("branch status not found")?; - - // create a wip commit. we'll use it to offload cherrypick conflicts calculation to libgit. - let wip_commit = { - let wip_tree_oid = write_tree(project_repository, &branch.head, branch_files)?; - let wip_tree = project_repository - .repo() - .find_tree(wip_tree_oid) - .context("failed to find tree")?; - - let signature = git2::Signature::now("GitButler", "gitbutler@gitbutler.com") - .context("failed to make gb signature")?; - let oid = project_repository - .repo() - .commit_with_signature( - None, - &signature, - &signature, - "wip cherry picking commit", - &wip_tree, - &[&branch_head_commit], - None, - ) - .context("failed to commit wip work")?; - project_repository - .repo() - .find_commit(oid) - .context("failed to find wip commit")? - }; - - let mut cherrypick_index = project_repository - .repo() - .cherrypick_commit(&target_commit, &wip_commit, 0, None) - .context("failed to cherry pick")?; - - // unapply other branches - for other_branch in applied_statuses - .iter() - .filter(|(b, _)| b.id != branch.id) - .map(|(b, _)| b) - { - unapply_branch(project_repository, other_branch.id).context("failed to unapply branch")?; - } - - let commit_oid = if cherrypick_index.has_conflicts() { - // checkout the conflicts - project_repository - .repo() - .checkout_index_builder(&mut cherrypick_index) - .allow_conflicts() - .conflict_style_merge() - .force() - .checkout() - .context("failed to checkout conflicts")?; - - // mark conflicts - let conflicts = cherrypick_index - .conflicts() - .context("failed to get conflicts")?; - let mut merge_conflicts = Vec::new(); - for path in conflicts.flatten() { - if let Some(ours) = path.our { - let path = std::str::from_utf8(&ours.path) - .context("failed to convert path")? - .to_string(); - merge_conflicts.push(path); - } - } - conflicts::mark(project_repository, &merge_conflicts, Some(branch.head))?; - - None - } else { - let merge_tree_oid = cherrypick_index - .write_tree_to(project_repository.repo()) - .context("failed to write merge tree")?; - let merge_tree = project_repository - .repo() - .find_tree(merge_tree_oid) - .context("failed to find merge tree")?; - - let branch_head_commit = project_repository - .repo() - .find_commit(branch.head) - .context("failed to find branch head commit")?; - - let change_id = target_commit.change_id(); - let commit_oid = project_repository - .repo() - .commit_with_signature( - None, - &target_commit.author(), - &target_commit.committer(), - &target_commit.message_bstr().to_str_lossy(), - &merge_tree, - &[&branch_head_commit], - change_id.as_deref(), - ) - .context("failed to create commit")?; - - // checkout final_tree into the working directory - project_repository - .repo() - .checkout_tree_builder(&merge_tree) - .force() - .remove_untracked() - .checkout() - .context("failed to checkout final tree")?; - - // update branch status - branch.head = commit_oid; - branch.updated_timestamp_ms = crate::time::now_ms(); - vb_state.set_branch(branch.clone())?; - - Some(commit_oid) - }; - - super::integration::update_gitbutler_integration(&vb_state, project_repository) - .context("failed to update gitbutler integration")?; - - Ok(commit_oid) -} - /// squashes a commit from a virtual branch into its parent. pub fn squash( project_repository: &project_repository::Repository, @@ -3413,9 +3019,6 @@ pub fn squash( // * has parents of the parents commit. let parents: Vec<_> = parent_commit.parents().collect(); - // use the squash commit's change id - let change_id = commit_to_squash.change_id(); - let new_commit_oid = project_repository .repo() .commit_with_signature( @@ -3429,7 +3032,8 @@ pub fn squash( ), &commit_to_squash.tree().context("failed to find tree")?, &parents.iter().collect::>(), - change_id.as_deref(), + // use the squash commit's headers + commit_to_squash.gitbutler_headers(), ) .context("failed to commit")?; @@ -3504,8 +3108,6 @@ pub fn update_commit_message( let parents: Vec<_> = target_commit.parents().collect(); - let change_id = target_commit.change_id(); - let new_commit_oid = project_repository .repo() .commit_with_signature( @@ -3515,7 +3117,7 @@ pub fn update_commit_message( message, &target_commit.tree().context("failed to find tree")?, &parents.iter().collect::>(), - change_id.as_deref(), + target_commit.gitbutler_headers(), ) .context("failed to commit")?; @@ -3660,7 +3262,6 @@ pub fn move_commit( .find_tree(new_destination_tree_oid) .context("failed to find tree")?; - let change_id = source_branch_head.change_id(); let new_destination_head_oid = project_repository .commit( user, @@ -3670,7 +3271,7 @@ pub fn move_commit( .repo() .find_commit(destination_branch.head) .context("failed to get dst branch head commit")?], - change_id.as_deref(), + source_branch_head.gitbutler_headers(), ) .context("failed to commit")?; @@ -3689,6 +3290,247 @@ pub fn create_virtual_branch_from_branch( upstream: &git::Refname, user: Option<&users::User>, ) -> Result { + fn apply_branch( + project_repository: &project_repository::Repository, + branch_id: BranchId, + user: Option<&users::User>, + ) -> Result { + project_repository.assure_resolved()?; + let repo = project_repository.repo(); + + let vb_state = project_repository.project().virtual_branches(); + let default_target = vb_state.get_default_target()?; + + let mut branch = vb_state.get_branch(branch_id)?; + + if branch.applied { + return Ok(branch.name); + } + + let target_commit = repo + .find_commit(default_target.sha) + .context("failed to find target commit")?; + let target_tree = target_commit.tree().context("failed to get target tree")?; + + // calculate the merge base and make sure it's the same as the target commit + // if not, we need to merge or rebase the branch to get it up to date + + let merge_base = repo + .merge_base(default_target.sha, branch.head) + .context(format!( + "failed to find merge base between {} and {}", + default_target.sha, branch.head + ))?; + if merge_base != default_target.sha { + // Branch is out of date, merge or rebase it + let merge_base_tree = repo + .find_commit(merge_base) + .context(format!("failed to find merge base commit {}", merge_base))? + .tree() + .context("failed to find merge base tree")?; + + let branch_tree = repo + .find_tree(branch.tree) + .context("failed to find branch tree")?; + + let mut merge_index = repo + .merge_trees(&merge_base_tree, &branch_tree, &target_tree, None) + .context("failed to merge trees")?; + + if merge_index.has_conflicts() { + // currently we can only deal with the merge problem branch + for branch in get_status_by_branch(project_repository, Some(&target_commit.id()))? + .0 + .into_iter() + .map(|(branch, _)| branch) + .filter(|branch| branch.applied) + { + convert_to_real_branch(project_repository, branch.id, Default::default())?; + } + + // apply the branch + branch.applied = true; + vb_state.set_branch(branch.clone())?; + + // checkout the conflicts + repo.checkout_index_builder(&mut merge_index) + .allow_conflicts() + .conflict_style_merge() + .force() + .checkout() + .context("failed to checkout index")?; + + // mark conflicts + let conflicts = merge_index + .conflicts() + .context("failed to get merge index conflicts")?; + let mut merge_conflicts = Vec::new(); + for path in conflicts.flatten() { + if let Some(ours) = path.our { + let path = std::str::from_utf8(&ours.path) + .context("failed to convert path to utf8")? + .to_string(); + merge_conflicts.push(path); + } + } + conflicts::mark( + project_repository, + &merge_conflicts, + Some(default_target.sha), + )?; + + return Ok(branch.name); + } + + let head_commit = repo + .find_commit(branch.head) + .context("failed to find head commit")?; + + let merged_branch_tree_oid = merge_index + .write_tree_to(project_repository.repo()) + .context("failed to write tree")?; + + let merged_branch_tree = repo + .find_tree(merged_branch_tree_oid) + .context("failed to find tree")?; + + let ok_with_force_push = branch.allow_rebasing; + if branch.upstream.is_some() && !ok_with_force_push { + // branch was pushed to upstream, and user doesn't like force pushing. + // create a merge commit to avoid the need of force pushing then. + + let new_branch_head = project_repository.commit( + user, + format!( + "Merged {}/{} into {}", + default_target.branch.remote(), + default_target.branch.branch(), + branch.name + ) + .as_str(), + &merged_branch_tree, + &[&head_commit, &target_commit], + None, + )?; + + // ok, update the virtual branch + branch.head = new_branch_head; + } else { + let rebase = cherry_rebase( + project_repository, + target_commit.id(), + target_commit.id(), + branch.head, + ); + let mut rebase_success = true; + let mut last_rebase_head = branch.head; + match rebase { + Ok(rebase_oid) => { + if let Some(oid) = rebase_oid { + last_rebase_head = oid; + } + } + Err(_) => { + rebase_success = false; + } + } + + if rebase_success { + // rebase worked out, rewrite the branch head + branch.head = last_rebase_head; + } else { + // rebase failed, do a merge commit + + // get tree from merge_tree_oid + let merge_tree = repo + .find_tree(merged_branch_tree_oid) + .context("failed to find tree")?; + + // commit the merge tree oid + let new_branch_head = project_repository + .commit( + user, + format!( + "Merged {}/{} into {}", + default_target.branch.remote(), + default_target.branch.branch(), + branch.name + ) + .as_str(), + &merge_tree, + &[&head_commit, &target_commit], + None, + ) + .context("failed to commit merge")?; + + branch.head = new_branch_head; + } + } + + branch.tree = repo + .find_commit(branch.head)? + .tree() + .map_err(anyhow::Error::from)? + .id(); + vb_state.set_branch(branch.clone())?; + } + + let wd_tree = project_repository.repo().get_wd_tree()?; + + let branch_tree = repo + .find_tree(branch.tree) + .context("failed to find branch tree")?; + + // check index for conflicts + let mut merge_index = repo + .merge_trees(&target_tree, &wd_tree, &branch_tree, None) + .context("failed to merge trees")?; + + if merge_index.has_conflicts() { + return Err(anyhow!("branch {branch_id} is in a conflicting state")) + .context(Marker::ProjectConflict); + } + + // apply the branch + branch.applied = true; + vb_state.set_branch(branch.clone())?; + + ensure_selected_for_changes(&vb_state).context("failed to ensure selected for changes")?; + // checkout the merge index + repo.checkout_index_builder(&mut merge_index) + .force() + .checkout() + .context("failed to checkout index")?; + + // Look for and handle the vbranch indicator commit + { + let head_commit = repo.find_commit(branch.head)?; + + if let Some(header) = head_commit.raw_header() { + if header + .lines() + .any(|line| line.starts_with("gitbutler-vbranch")) + { + if let Some(branch_name) = header + .lines() + .find(|line| line.starts_with("gitbutler-vbranch-name")) + .and_then(|line| line.split_once(' ')) + .map(|(_, name)| name.to_string()) + { + branch.name = branch_name; + vb_state.set_branch(branch.clone())?; + } + + undo_commit(project_repository, branch_id, branch.head)?; + } + } + } + + super::integration::update_gitbutler_integration(&vb_state, project_repository)?; + + Ok(branch.name) + } + // only set upstream if it's not the default target let upstream_branch = match upstream { git::Refname::Other(_) | git::Refname::Virtual(_) => { @@ -3738,7 +3580,7 @@ pub fn create_virtual_branch_from_branch( .into_iter() .collect::>(); - let order = all_virtual_branches.len(); + let order = vb_state.next_order_index()?; let selected_for_changes = (!all_virtual_branches .iter() diff --git a/crates/gitbutler-core/tests/suite/virtual_branches/apply_virtual_branch.rs b/crates/gitbutler-core/tests/suite/virtual_branches/apply_virtual_branch.rs index 3b9bf6db00..2aa4b41b98 100644 --- a/crates/gitbutler-core/tests/suite/virtual_branches/apply_virtual_branch.rs +++ b/crates/gitbutler-core/tests/suite/virtual_branches/apply_virtual_branch.rs @@ -1,62 +1,5 @@ use super::*; -#[tokio::test] -async fn deltect_conflict() { - let Test { - repository, - project_id, - controller, - .. - } = &Test::default(); - - controller - .set_base_branch(*project_id, &"refs/remotes/origin/master".parse().unwrap()) - .await - .unwrap(); - - let branch1_id = { - let branch1_id = controller - .create_virtual_branch(*project_id, &branch::BranchCreateRequest::default()) - .await - .unwrap(); - fs::write(repository.path().join("file.txt"), "branch one").unwrap(); - - branch1_id - }; - - // unapply first vbranch - controller - .unapply_virtual_branch(*project_id, branch1_id) - .await - .unwrap(); - - { - // create another vbranch that conflicts with the first one - controller - .create_virtual_branch(*project_id, &branch::BranchCreateRequest::default()) - .await - .unwrap(); - fs::write(repository.path().join("file.txt"), "branch two").unwrap(); - } - - { - // it should not be possible to apply the first branch - assert!(!controller - .can_apply_virtual_branch(*project_id, branch1_id) - .await - .unwrap()); - - assert!(matches!( - controller - .apply_virtual_branch(*project_id, branch1_id) - .await - .unwrap_err() - .downcast_ref(), - Some(Marker::ProjectConflict) - )); - } -} - #[tokio::test] async fn rebase_commit() { let Test { @@ -82,7 +25,7 @@ async fn rebase_commit() { .await .unwrap(); - let branch1_id = { + let mut branch1_id = { // create a branch with some commited work let branch1_id = controller .create_virtual_branch(*project_id, &branch::BranchCreateRequest::default()) @@ -105,10 +48,10 @@ async fn rebase_commit() { branch1_id }; - { + let unapplied_branch = { // unapply first vbranch - controller - .unapply_virtual_branch(*project_id, branch1_id) + let unapplied_branch = controller + .convert_to_real_branch(*project_id, branch1_id, Default::default()) .await .unwrap(); @@ -122,12 +65,10 @@ async fn rebase_commit() { ); let (branches, _) = controller.list_virtual_branches(*project_id).await.unwrap(); - assert_eq!(branches.len(), 1); - assert_eq!(branches[0].id, branch1_id); - assert_eq!(branches[0].files.len(), 0); - assert_eq!(branches[0].commits.len(), 1); - assert!(!branches[0].active); - } + assert_eq!(branches.len(), 0); + + git::Refname::from_str(&unapplied_branch).unwrap() + }; { // fetch remote @@ -135,12 +76,7 @@ async fn rebase_commit() { // branch is stil unapplied let (branches, _) = controller.list_virtual_branches(*project_id).await.unwrap(); - assert_eq!(branches.len(), 1); - assert_eq!(branches[0].id, branch1_id); - assert_eq!(branches[0].files.len(), 0); - assert_eq!(branches[0].commits.len(), 1); - assert!(!branches[0].active); - assert!(!branches[0].conflicted); + assert_eq!(branches.len(), 0); assert_eq!( fs::read_to_string(repository.path().join("another_file.txt")).unwrap(), @@ -154,8 +90,8 @@ async fn rebase_commit() { { // apply first vbranch again - controller - .apply_virtual_branch(*project_id, branch1_id) + branch1_id = controller + .create_virtual_branch_from_branch(*project_id, &unapplied_branch) .await .unwrap(); @@ -203,7 +139,7 @@ async fn rebase_work() { .await .unwrap(); - let branch1_id = { + let mut branch1_id = { // make a branch with some work let branch1_id = controller .create_virtual_branch(*project_id, &branch::BranchCreateRequest::default()) @@ -221,23 +157,21 @@ async fn rebase_work() { branch1_id }; - { + let unapplied_branch = { // unapply first vbranch - controller - .unapply_virtual_branch(*project_id, branch1_id) + let unapplied_branch = controller + .convert_to_real_branch(*project_id, branch1_id, Default::default()) .await .unwrap(); let (branches, _) = controller.list_virtual_branches(*project_id).await.unwrap(); - assert_eq!(branches.len(), 1); - assert_eq!(branches[0].id, branch1_id); - assert_eq!(branches[0].files.len(), 1); - assert_eq!(branches[0].commits.len(), 0); - assert!(!branches[0].active); + assert_eq!(branches.len(), 0); assert!(!repository.path().join("another_file.txt").exists()); assert!(!repository.path().join("file.txt").exists()); - } + + git::Refname::from_str(&unapplied_branch).unwrap() + }; { // fetch remote @@ -245,12 +179,7 @@ async fn rebase_work() { // first branch is stil unapplied let (branches, _) = controller.list_virtual_branches(*project_id).await.unwrap(); - assert_eq!(branches.len(), 1); - assert_eq!(branches[0].id, branch1_id); - assert_eq!(branches[0].files.len(), 1); - assert_eq!(branches[0].commits.len(), 0); - assert!(!branches[0].active); - assert!(!branches[0].conflicted); + assert_eq!(branches.len(), 0); assert!(!repository.path().join("another_file.txt").exists()); assert!(repository.path().join("file.txt").exists()); @@ -258,8 +187,8 @@ async fn rebase_work() { { // apply first vbranch again - controller - .apply_virtual_branch(*project_id, branch1_id) + branch1_id = controller + .create_virtual_branch_from_branch(*project_id, &unapplied_branch) .await .unwrap(); diff --git a/crates/gitbutler-core/tests/suite/virtual_branches/cherry_pick.rs b/crates/gitbutler-core/tests/suite/virtual_branches/cherry_pick.rs deleted file mode 100644 index 24769965ec..0000000000 --- a/crates/gitbutler-core/tests/suite/virtual_branches/cherry_pick.rs +++ /dev/null @@ -1,386 +0,0 @@ -use super::*; - -mod cleanly { - - use super::*; - - #[tokio::test] - async fn applied() { - let Test { - repository, - project_id, - controller, - .. - } = &Test::default(); - - controller - .set_base_branch(*project_id, &"refs/remotes/origin/master".parse().unwrap()) - .await - .unwrap(); - - let branch_id = controller - .create_virtual_branch(*project_id, &branch::BranchCreateRequest::default()) - .await - .unwrap(); - - let commit_one = { - fs::write(repository.path().join("file.txt"), "content").unwrap(); - controller - .create_commit(*project_id, branch_id, "commit", None, false) - .await - .unwrap() - }; - - let commit_two = { - fs::write(repository.path().join("file.txt"), "content two").unwrap(); - controller - .create_commit(*project_id, branch_id, "commit", None, false) - .await - .unwrap() - }; - - controller - .push_virtual_branch(*project_id, branch_id, false, None) - .await - .unwrap(); - - controller - .reset_virtual_branch(*project_id, branch_id, commit_one) - .await - .unwrap(); - - repository.reset_hard(None); - - assert_eq!( - fs::read_to_string(repository.path().join("file.txt")).unwrap(), - "content" - ); - - let cherry_picked_commit_oid = controller - .cherry_pick(*project_id, branch_id, commit_two) - .await - .unwrap(); - assert!(cherry_picked_commit_oid.is_some()); - assert!(repository.path().join("file.txt").exists()); - assert_eq!( - fs::read_to_string(repository.path().join("file.txt")).unwrap(), - "content two" - ); - - let (branches, _) = controller.list_virtual_branches(*project_id).await.unwrap(); - assert_eq!(branches.len(), 1); - assert_eq!(branches[0].id, branch_id); - assert!(branches[0].active); - assert_eq!(branches[0].commits.len(), 2); - assert_eq!(branches[0].commits[0].id, cherry_picked_commit_oid.unwrap()); - assert_eq!(branches[0].commits[1].id, commit_one); - } - - #[tokio::test] - async fn to_different_branch() { - let Test { - repository, - project_id, - controller, - .. - } = &Test::default(); - - controller - .set_base_branch(*project_id, &"refs/remotes/origin/master".parse().unwrap()) - .await - .unwrap(); - - let branch_id = controller - .create_virtual_branch(*project_id, &branch::BranchCreateRequest::default()) - .await - .unwrap(); - - let commit_one = { - fs::write(repository.path().join("file.txt"), "content").unwrap(); - controller - .create_commit(*project_id, branch_id, "commit", None, false) - .await - .unwrap() - }; - - let commit_two = { - fs::write(repository.path().join("file_two.txt"), "content two").unwrap(); - controller - .create_commit(*project_id, branch_id, "commit", None, false) - .await - .unwrap() - }; - - controller - .push_virtual_branch(*project_id, branch_id, false, None) - .await - .unwrap(); - - controller - .reset_virtual_branch(*project_id, branch_id, commit_one) - .await - .unwrap(); - - repository.reset_hard(None); - - assert_eq!( - fs::read_to_string(repository.path().join("file.txt")).unwrap(), - "content" - ); - assert!(!repository.path().join("file_two.txt").exists()); - - let branch_two_id = controller - .create_virtual_branch(*project_id, &branch::BranchCreateRequest::default()) - .await - .unwrap(); - - let cherry_picked_commit_oid = controller - .cherry_pick(*project_id, branch_two_id, commit_two) - .await - .unwrap(); - assert!(cherry_picked_commit_oid.is_some()); - - let (branches, _) = controller.list_virtual_branches(*project_id).await.unwrap(); - assert!(repository.path().join("file_two.txt").exists()); - assert_eq!( - fs::read_to_string(repository.path().join("file_two.txt")).unwrap(), - "content two" - ); - - assert_eq!(branches.len(), 2); - assert_eq!(branches[0].id, branch_id); - assert!(!branches[0].active); - assert_eq!(branches[0].commits.len(), 1); - assert_eq!(branches[0].commits[0].id, commit_one); - - assert_eq!(branches[1].id, branch_two_id); - assert!(branches[1].active); - assert_eq!(branches[1].commits.len(), 1); - assert_eq!(branches[1].commits[0].id, cherry_picked_commit_oid.unwrap()); - } - - #[tokio::test] - async fn non_applied() { - let Test { - repository, - project_id, - controller, - .. - } = &Test::default(); - - controller - .set_base_branch(*project_id, &"refs/remotes/origin/master".parse().unwrap()) - .await - .unwrap(); - - let branch_id = controller - .create_virtual_branch(*project_id, &branch::BranchCreateRequest::default()) - .await - .unwrap(); - - let commit_one_oid = { - fs::write(repository.path().join("file.txt"), "content").unwrap(); - controller - .create_commit(*project_id, branch_id, "commit", None, false) - .await - .unwrap() - }; - - { - fs::write(repository.path().join("file_two.txt"), "content two").unwrap(); - controller - .create_commit(*project_id, branch_id, "commit", None, false) - .await - .unwrap() - }; - - let commit_three_oid = { - fs::write(repository.path().join("file_three.txt"), "content three").unwrap(); - controller - .create_commit(*project_id, branch_id, "commit", None, false) - .await - .unwrap() - }; - - controller - .reset_virtual_branch(*project_id, branch_id, commit_one_oid) - .await - .unwrap(); - - controller - .unapply_virtual_branch(*project_id, branch_id) - .await - .unwrap(); - - assert_eq!( - controller - .cherry_pick(*project_id, branch_id, commit_three_oid) - .await - .unwrap_err() - .to_string(), - "can not cherry pick a branch that is not applied" - ); - } -} - -mod with_conflicts { - - use super::*; - - #[tokio::test] - async fn applied() { - let Test { - repository, - project_id, - controller, - .. - } = &Test::default(); - - controller - .set_base_branch(*project_id, &"refs/remotes/origin/master".parse().unwrap()) - .await - .unwrap(); - - let branch_id = controller - .create_virtual_branch(*project_id, &branch::BranchCreateRequest::default()) - .await - .unwrap(); - - let commit_one = { - fs::write(repository.path().join("file.txt"), "content").unwrap(); - controller - .create_commit(*project_id, branch_id, "commit one", None, false) - .await - .unwrap() - }; - - { - fs::write(repository.path().join("file_two.txt"), "content two").unwrap(); - controller - .create_commit(*project_id, branch_id, "commit two", None, false) - .await - .unwrap() - }; - - let commit_three = { - fs::write(repository.path().join("file_three.txt"), "content three").unwrap(); - controller - .create_commit(*project_id, branch_id, "commit three", None, false) - .await - .unwrap() - }; - - controller - .push_virtual_branch(*project_id, branch_id, false, None) - .await - .unwrap(); - - controller - .reset_virtual_branch(*project_id, branch_id, commit_one) - .await - .unwrap(); - - repository.reset_hard(None); - assert_eq!( - fs::read_to_string(repository.path().join("file.txt")).unwrap(), - "content" - ); - assert!(!repository.path().join("file_two.txt").exists()); - assert!(!repository.path().join("file_three.txt").exists()); - - // introduce conflict with the remote commit - fs::write(repository.path().join("file_three.txt"), "conflict").unwrap(); - - { - // cherry picking leads to conflict - let cherry_picked_commit_oid = controller - .cherry_pick(*project_id, branch_id, commit_three) - .await - .unwrap(); - assert!(cherry_picked_commit_oid.is_none()); - - assert_eq!( - fs::read_to_string(repository.path().join("file_three.txt")).unwrap(), - "<<<<<<< ours\nconflict\n=======\ncontent three\n>>>>>>> theirs\n" - ); - - let (branches, _) = controller.list_virtual_branches(*project_id).await.unwrap(); - assert_eq!(branches.len(), 1); - assert_eq!(branches[0].id, branch_id); - assert!(branches[0].active); - assert!(branches[0].conflicted); - assert_eq!(branches[0].files.len(), 1); - assert!(branches[0].files[0].conflicted); - assert_eq!(branches[0].commits.len(), 1); - } - - { - // conflict can be resolved - fs::write(repository.path().join("file_three.txt"), "resolved").unwrap(); - let commited_oid = controller - .create_commit(*project_id, branch_id, "resolution", None, false) - .await - .unwrap(); - - let commit = repository.find_commit(commited_oid).unwrap(); - assert_eq!(commit.parent_count(), 2); - - let (branches, _) = controller.list_virtual_branches(*project_id).await.unwrap(); - assert_eq!(branches.len(), 1); - assert_eq!(branches[0].id, branch_id); - assert!(branches[0].active); - assert!(branches[0].requires_force); - assert!(!branches[0].conflicted); - assert_eq!(branches[0].commits.len(), 2); - // resolution commit is there - assert_eq!(branches[0].commits[0].id, commited_oid); - assert_eq!(branches[0].commits[1].id, commit_one); - } - } - - #[tokio::test] - async fn non_applied() { - let Test { - repository, - project_id, - controller, - .. - } = &Test::default(); - - let commit_oid = { - let first = repository.commit_all("commit"); - fs::write(repository.path().join("file.txt"), "content").unwrap(); - let second = repository.commit_all("commit"); - repository.push(); - repository.reset_hard(Some(first)); - second - }; - - controller - .set_base_branch(*project_id, &"refs/remotes/origin/master".parse().unwrap()) - .await - .unwrap(); - - let branch_id = controller - .create_virtual_branch(*project_id, &branch::BranchCreateRequest::default()) - .await - .unwrap(); - - // introduce conflict with the remote commit - fs::write(repository.path().join("file.txt"), "conflict").unwrap(); - - controller - .unapply_virtual_branch(*project_id, branch_id) - .await - .unwrap(); - - assert_eq!( - controller - .cherry_pick(*project_id, branch_id, commit_oid) - .await - .unwrap_err() - .to_string(), - "can not cherry pick a branch that is not applied" - ); - } -} diff --git a/crates/gitbutler-core/tests/suite/virtual_branches/unapply.rs b/crates/gitbutler-core/tests/suite/virtual_branches/convert_to_real_branch.rs similarity index 68% rename from crates/gitbutler-core/tests/suite/virtual_branches/unapply.rs rename to crates/gitbutler-core/tests/suite/virtual_branches/convert_to_real_branch.rs index 3aefa45623..9ea333d102 100644 --- a/crates/gitbutler-core/tests/suite/virtual_branches/unapply.rs +++ b/crates/gitbutler-core/tests/suite/virtual_branches/convert_to_real_branch.rs @@ -20,15 +20,14 @@ async fn unapply_with_data() { assert_eq!(branches.len(), 1); controller - .unapply_virtual_branch(*project_id, branches[0].id) + .convert_to_real_branch(*project_id, branches[0].id, Default::default()) .await .unwrap(); assert!(!repository.path().join("file.txt").exists()); let (branches, _) = controller.list_virtual_branches(*project_id).await.unwrap(); - assert_eq!(branches.len(), 1); - assert!(!branches[0].active); + assert_eq!(branches.len(), 0); } #[tokio::test] @@ -55,7 +54,7 @@ async fn conflicting() { .await .unwrap(); - let branch_id = { + let unapplied_branch = { // make a conflicting branch, and stash it std::fs::write(repository.path().join("file.txt"), "conflict").unwrap(); @@ -69,12 +68,12 @@ async fn conflicting() { "@@ -1 +1 @@\n-first\n\\ No newline at end of file\n+conflict\n\\ No newline at end of file\n" ); - controller - .unapply_virtual_branch(*project_id, branches[0].id) + let unapplied_branch = controller + .convert_to_real_branch(*project_id, branches[0].id, Default::default()) .await .unwrap(); - branches[0].id + git::Refname::from_str(&unapplied_branch).unwrap() }; { @@ -85,23 +84,12 @@ async fn conflicting() { std::fs::read_to_string(repository.path().join("file.txt")).unwrap(), "second" ); - - let branch = controller - .list_virtual_branches(*project_id) - .await - .unwrap() - .0 - .into_iter() - .find(|branch| branch.id == branch_id) - .unwrap(); - assert!(!branch.base_current); - assert!(!branch.active); } - { + let branch_id = { // apply branch, it should conflict - controller - .apply_virtual_branch(*project_id, branch_id) + let branch_id = controller + .create_virtual_branch_from_branch(*project_id, &unapplied_branch) .await .unwrap(); @@ -110,22 +98,24 @@ async fn conflicting() { "<<<<<<< ours\nconflict\n=======\nsecond\n>>>>>>> theirs\n" ); - let branch = controller - .list_virtual_branches(*project_id) - .await - .unwrap() - .0 - .into_iter() - .find(|b| b.id == branch_id) - .unwrap(); - assert!(branch.base_current); + let (branches, _) = controller.list_virtual_branches(*project_id).await.unwrap(); + + assert_eq!(branches.len(), 1); + let branch = &branches[0]; + // assert!(!branch.base_current); assert!(branch.conflicted); - assert_eq!(branch.files[0].hunks[0].diff, "@@ -1 +1,5 @@\n-first\n\\ No newline at end of file\n+<<<<<<< ours\n+conflict\n+=======\n+second\n+>>>>>>> theirs\n"); - } + assert_eq!( + branch.files[0].hunks[0].diff, + "@@ -1 +1,5 @@\n-first\n\\ No newline at end of file\n+<<<<<<< ours\n+conflict\n+=======\n+second\n+>>>>>>> theirs\n" + ); + + branch_id + }; { + // Converting the branch to a real branch should put us back in an unconflicted state controller - .unapply_virtual_branch(*project_id, branch_id) + .convert_to_real_branch(*project_id, branch_id, Default::default()) .await .unwrap(); @@ -133,22 +123,6 @@ async fn conflicting() { std::fs::read_to_string(repository.path().join("file.txt")).unwrap(), "second" ); - - let branch = controller - .list_virtual_branches(*project_id) - .await - .unwrap() - .0 - .into_iter() - .find(|b| b.id == branch_id) - .unwrap(); - assert!(!branch.active); - assert!(!branch.base_current); - assert!(!branch.conflicted); - assert_eq!( - branch.files[0].hunks[0].diff, - "@@ -1 +1 @@\n-first\n\\ No newline at end of file\n+conflict\n\\ No newline at end of file\n" - ); } } @@ -174,7 +148,7 @@ async fn delete_if_empty() { assert_eq!(branches.len(), 1); controller - .unapply_virtual_branch(*project_id, branches[0].id) + .convert_to_real_branch(*project_id, branches[0].id, Default::default()) .await .unwrap(); diff --git a/crates/gitbutler-core/tests/suite/virtual_branches/mod.rs b/crates/gitbutler-core/tests/suite/virtual_branches/mod.rs index 8f812f0152..e5cad36093 100644 --- a/crates/gitbutler-core/tests/suite/virtual_branches/mod.rs +++ b/crates/gitbutler-core/tests/suite/virtual_branches/mod.rs @@ -64,7 +64,7 @@ impl Test { mod amend; mod apply_virtual_branch; -mod cherry_pick; +mod convert_to_real_branch; mod create_commit; mod create_virtual_branch_from_branch; mod delete_virtual_branch; @@ -80,7 +80,6 @@ mod reset_virtual_branch; mod selected_for_changes; mod set_base_branch; mod squash; -mod unapply; mod unapply_ownership; mod undo_commit; mod update_base_branch; @@ -114,7 +113,7 @@ async fn resolve_conflict_flow() { .await .unwrap(); - let branch1_id = { + { // make a branch that conflicts with the remote branch, but doesn't know about it yet let branch1_id = controller .create_virtual_branch(*project_id, &branch::BranchCreateRequest::default()) @@ -126,31 +125,29 @@ async fn resolve_conflict_flow() { assert_eq!(branches.len(), 1); assert_eq!(branches[0].id, branch1_id); assert!(branches[0].active); - - branch1_id }; - { - // fetch remote - controller.update_base_branch(*project_id).await.unwrap(); + let unapplied_branch = { + // fetch remote. There is now a conflict, so the branch will be unapplied + let unapplied_branches = controller.update_base_branch(*project_id).await.unwrap(); + assert_eq!(unapplied_branches.len(), 1); // there is a conflict now, so the branch should be inactive let (branches, _) = controller.list_virtual_branches(*project_id).await.unwrap(); - assert_eq!(branches.len(), 1); - assert_eq!(branches[0].id, branch1_id); - assert!(!branches[0].active); - } + assert_eq!(branches.len(), 0); - { + git::Refname::from_str(&unapplied_branches[0]).unwrap() + }; + + let branch1_id = { // when we apply conflicted branch, it has conflict - controller - .apply_virtual_branch(*project_id, branch1_id) + let branch1_id = controller + .create_virtual_branch_from_branch(*project_id, &unapplied_branch) .await .unwrap(); let (branches, _) = controller.list_virtual_branches(*project_id).await.unwrap(); assert_eq!(branches.len(), 1); - assert_eq!(branches[0].id, branch1_id); assert!(branches[0].active); assert!(branches[0].conflicted); assert_eq!(branches[0].files.len(), 2); // third.txt should be present during conflict @@ -160,7 +157,9 @@ async fn resolve_conflict_flow() { fs::read_to_string(repository.path().join("file.txt")).unwrap(), "<<<<<<< ours\nconflict\n=======\nsecond\n>>>>>>> theirs\n" ); - } + + branch1_id + }; { // can't commit conflicts @@ -177,6 +176,7 @@ async fn resolve_conflict_flow() { { // fixing the conflict removes conflicted mark fs::write(repository.path().join("file.txt"), "resolved").unwrap(); + controller.list_virtual_branches(*project_id).await.unwrap(); let commit_oid = controller .create_commit(*project_id, branch1_id, "resolution", None, false) .await diff --git a/crates/gitbutler-core/tests/suite/virtual_branches/move_commit_file.rs b/crates/gitbutler-core/tests/suite/virtual_branches/move_commit_file.rs index baac604650..66d853f4e8 100644 --- a/crates/gitbutler-core/tests/suite/virtual_branches/move_commit_file.rs +++ b/crates/gitbutler-core/tests/suite/virtual_branches/move_commit_file.rs @@ -194,7 +194,6 @@ async fn move_file_up_overlapping_hunks() { .find(|b| b.id == branch_id) .unwrap(); - dbg!(&branch.commits); assert_eq!(branch.commits.len(), 4); // } diff --git a/crates/gitbutler-core/tests/suite/virtual_branches/selected_for_changes.rs b/crates/gitbutler-core/tests/suite/virtual_branches/selected_for_changes.rs index e6727dea07..1d746628d5 100644 --- a/crates/gitbutler-core/tests/suite/virtual_branches/selected_for_changes.rs +++ b/crates/gitbutler-core/tests/suite/virtual_branches/selected_for_changes.rs @@ -38,19 +38,16 @@ async fn unapplying_selected_branch_selects_anther() { assert!(!b2.selected_for_changes); controller - .unapply_virtual_branch(*project_id, b_id) + .convert_to_real_branch(*project_id, b_id, Default::default()) .await .unwrap(); let (branches, _) = controller.list_virtual_branches(*project_id).await.unwrap(); - assert_eq!(branches.len(), 2); - assert_eq!(branches[0].id, b.id); - assert!(!branches[0].selected_for_changes); - assert!(!branches[0].active); - assert_eq!(branches[1].id, b2.id); - assert!(branches[1].selected_for_changes); - assert!(branches[1].active); + assert_eq!(branches.len(), 1); + assert_eq!(branches[0].id, b2.id); + assert!(branches[0].selected_for_changes); + assert!(branches[0].active); } #[tokio::test] @@ -289,20 +286,33 @@ async fn unapply_virtual_branch_should_reset_selected_for_changes() { .unwrap(); assert!(b1.selected_for_changes); - controller - .unapply_virtual_branch(*project_id, b1_id) + let b2_id = controller + .create_virtual_branch(*project_id, &branch::BranchCreateRequest::default()) .await .unwrap(); - let b1 = controller + let b2 = controller .list_virtual_branches(*project_id) .await .unwrap() .0 .into_iter() - .find(|b| b.id == b1_id) + .find(|b| b.id == b2_id) .unwrap(); - assert!(!b1.selected_for_changes); + assert!(!b2.selected_for_changes); + + controller + .convert_to_real_branch(*project_id, b1_id, Default::default()) + .await + .unwrap(); + + assert!(controller + .list_virtual_branches(*project_id) + .await + .unwrap() + .0 + .into_iter() + .any(|b| b.selected_for_changes && b.id != b1_id)) } #[tokio::test] @@ -359,12 +369,13 @@ async fn applying_first_branch() { let (branches, _) = controller.list_virtual_branches(*project_id).await.unwrap(); assert_eq!(branches.len(), 1); - controller - .unapply_virtual_branch(*project_id, branches[0].id) + let unapplied_branch = controller + .convert_to_real_branch(*project_id, branches[0].id, Default::default()) .await .unwrap(); + let unapplied_branch = git::Refname::from_str(&unapplied_branch).unwrap(); controller - .apply_virtual_branch(*project_id, branches[0].id) + .create_virtual_branch_from_branch(*project_id, &unapplied_branch) .await .unwrap(); @@ -424,7 +435,6 @@ async fn new_locked_hunk_without_modifying_existing() { repository.write_file("file.txt", &lines); let (branches, _) = controller.list_virtual_branches(*project_id).await.unwrap(); - dbg!(&branches); assert_eq!(branches[0].files.len(), 0); assert_eq!(branches[1].files.len(), 1); diff --git a/crates/gitbutler-core/tests/suite/virtual_branches/update_base_branch.rs b/crates/gitbutler-core/tests/suite/virtual_branches/update_base_branch.rs index cc300a7a73..aea65fcc51 100644 --- a/crates/gitbutler-core/tests/suite/virtual_branches/update_base_branch.rs +++ b/crates/gitbutler-core/tests/suite/virtual_branches/update_base_branch.rs @@ -1,770 +1,5 @@ use super::*; -mod unapplied_branch { - - use super::*; - - #[tokio::test] - async fn conflicts_with_uncommitted_work() { - let Test { - repository, - project_id, - controller, - .. - } = &Test::default(); - - // make sure we have an undiscovered commit in the remote branch - { - fs::write(repository.path().join("file.txt"), "first").unwrap(); - let first_commit_oid = repository.commit_all("first"); - fs::write(repository.path().join("file.txt"), "second").unwrap(); - repository.commit_all("second"); - repository.push(); - repository.reset_hard(Some(first_commit_oid)); - } - - controller - .set_base_branch(*project_id, &"refs/remotes/origin/master".parse().unwrap()) - .await - .unwrap(); - - let branch_id = { - // make a branch that is unapplied and contains not commited conflict - let branch_id = controller - .create_virtual_branch(*project_id, &branch::BranchCreateRequest::default()) - .await - .unwrap(); - - fs::write(repository.path().join("file.txt"), "conflict").unwrap(); - controller - .unapply_virtual_branch(*project_id, branch_id) - .await - .unwrap(); - - branch_id - }; - - { - // when fetching remote - controller.update_base_branch(*project_id).await.unwrap(); - - // branch should not be changed. - - let (branches, _) = controller.list_virtual_branches(*project_id).await.unwrap(); - assert_eq!(branches.len(), 1); - assert_eq!(branches[0].id, branch_id); - assert!(!branches[0].active); - assert!(!branches[0].base_current); - assert_eq!(branches[0].files.len(), 1); - assert_eq!(branches[0].commits.len(), 0); - assert!(!controller - .can_apply_virtual_branch(*project_id, branch_id) - .await - .unwrap()); - } - - { - // applying the branch should produce conflict markers - controller - .apply_virtual_branch(*project_id, branch_id) - .await - .unwrap(); - let (branches, _) = controller.list_virtual_branches(*project_id).await.unwrap(); - assert_eq!(branches.len(), 1); - assert_eq!(branches[0].id, branch_id); - assert!(branches[0].active); - assert!(branches[0].conflicted); - assert!(branches[0].base_current); - assert_eq!(branches[0].files.len(), 1); - assert_eq!(branches[0].commits.len(), 0); - assert_eq!( - std::fs::read_to_string(repository.path().join("file.txt")).unwrap(), - "<<<<<<< ours\nconflict\n=======\nsecond\n>>>>>>> theirs\n" - ); - } - } - - #[tokio::test] - async fn commited_conflict_not_pushed() { - let Test { - repository, - project_id, - controller, - .. - } = &Test::default(); - - // make sure we have an undiscovered commit in the remote branch - { - fs::write(repository.path().join("file.txt"), "first").unwrap(); - let first_commit_oid = repository.commit_all("first"); - fs::write(repository.path().join("file.txt"), "second").unwrap(); - repository.commit_all("second"); - repository.push(); - repository.reset_hard(Some(first_commit_oid)); - } - - controller - .set_base_branch(*project_id, &"refs/remotes/origin/master".parse().unwrap()) - .await - .unwrap(); - - let branch_id = { - // make a branch with a commit that conflicts with upstream, and work that fixes - // that conflict - let branch_id = controller - .create_virtual_branch(*project_id, &branch::BranchCreateRequest::default()) - .await - .unwrap(); - - fs::write(repository.path().join("file.txt"), "conflict").unwrap(); - controller - .create_commit(*project_id, branch_id, "conflicting commit", None, false) - .await - .unwrap(); - - controller - .unapply_virtual_branch(*project_id, branch_id) - .await - .unwrap(); - - branch_id - }; - - { - // when fetching remote - controller.update_base_branch(*project_id).await.unwrap(); - - // should not change the branch. - - let (branches, _) = controller.list_virtual_branches(*project_id).await.unwrap(); - assert_eq!(branches.len(), 1); - assert_eq!(branches[0].id, branch_id); - assert!(!branches[0].active); - assert!(!branches[0].base_current); - assert_eq!(branches[0].files.len(), 0); - assert_eq!(branches[0].commits.len(), 1); - assert!(!controller - .can_apply_virtual_branch(*project_id, branch_id) - .await - .unwrap()); - } - - { - // applying the branch should produce conflict markers - controller - .apply_virtual_branch(*project_id, branch_id) - .await - .unwrap(); - let (branches, _) = controller.list_virtual_branches(*project_id).await.unwrap(); - assert_eq!(branches.len(), 1); - assert_eq!(branches[0].id, branch_id); - assert!(branches[0].active); - assert!(branches[0].conflicted); - assert!(branches[0].base_current); - assert_eq!(branches[0].files.len(), 1); - assert_eq!(branches[0].commits.len(), 1); - assert_eq!( - std::fs::read_to_string(repository.path().join("file.txt")).unwrap(), - "<<<<<<< ours\nconflict\n=======\nsecond\n>>>>>>> theirs\n" - ); - } - } - - #[tokio::test] - async fn commited_conflict_pushed() { - let Test { - repository, - project_id, - controller, - .. - } = &Test::default(); - - // make sure we have an undiscovered commit in the remote branch - { - fs::write(repository.path().join("file.txt"), "first").unwrap(); - let first_commit_oid = repository.commit_all("first"); - fs::write(repository.path().join("file.txt"), "second").unwrap(); - repository.commit_all("second"); - repository.push(); - repository.reset_hard(Some(first_commit_oid)); - } - - controller - .set_base_branch(*project_id, &"refs/remotes/origin/master".parse().unwrap()) - .await - .unwrap(); - - let branch_id = { - // make a branch with a commit that conflicts with upstream, and work that fixes - // that conflict - let branch_id = controller - .create_virtual_branch(*project_id, &branch::BranchCreateRequest::default()) - .await - .unwrap(); - - fs::write(repository.path().join("file.txt"), "conflict").unwrap(); - controller - .create_commit(*project_id, branch_id, "conflicting commit", None, false) - .await - .unwrap(); - - controller - .push_virtual_branch(*project_id, branch_id, false, None) - .await - .unwrap(); - - controller - .unapply_virtual_branch(*project_id, branch_id) - .await - .unwrap(); - - branch_id - }; - - { - // when fetching remote - controller.update_base_branch(*project_id).await.unwrap(); - - // should not change the branch. - - let (branches, _) = controller.list_virtual_branches(*project_id).await.unwrap(); - assert_eq!(branches.len(), 1); - assert_eq!(branches[0].id, branch_id); - assert!(!branches[0].active); - assert!(!branches[0].base_current); - assert_eq!(branches[0].files.len(), 0); - assert_eq!(branches[0].commits.len(), 1); - assert!(!controller - .can_apply_virtual_branch(*project_id, branch_id) - .await - .unwrap()); - } - - { - // applying the branch should produce conflict markers - controller - .apply_virtual_branch(*project_id, branch_id) - .await - .unwrap(); - let (branches, _) = controller.list_virtual_branches(*project_id).await.unwrap(); - assert_eq!(branches.len(), 1); - assert_eq!(branches[0].id, branch_id); - assert!(branches[0].active); - assert!(branches[0].conflicted); - assert!(branches[0].base_current); - assert_eq!(branches[0].files.len(), 1); - assert_eq!(branches[0].commits.len(), 1); - assert_eq!( - std::fs::read_to_string(repository.path().join("file.txt")).unwrap(), - "<<<<<<< ours\nconflict\n=======\nsecond\n>>>>>>> theirs\n" - ); - } - } - - #[tokio::test] - async fn commited_conflict_not_pushed_fixed_with_more_work() { - let Test { - repository, - project_id, - controller, - .. - } = &Test::default(); - - // make sure we have an undiscovered commit in the remote branch - { - fs::write(repository.path().join("file.txt"), "first").unwrap(); - let first_commit_oid = repository.commit_all("first"); - fs::write(repository.path().join("file.txt"), "second").unwrap(); - repository.commit_all("second"); - repository.push(); - repository.reset_hard(Some(first_commit_oid)); - } - - controller - .set_base_branch(*project_id, &"refs/remotes/origin/master".parse().unwrap()) - .await - .unwrap(); - - let branch_id = { - // make a branch with a commit that conflicts with upstream, and work that fixes - // that conflict - let branch_id = controller - .create_virtual_branch(*project_id, &branch::BranchCreateRequest::default()) - .await - .unwrap(); - - fs::write(repository.path().join("file.txt"), "conflict").unwrap(); - controller - .create_commit(*project_id, branch_id, "conflicting commit", None, false) - .await - .unwrap(); - - fs::write(repository.path().join("file.txt"), "fix conflict").unwrap(); - - controller - .unapply_virtual_branch(*project_id, branch_id) - .await - .unwrap(); - - branch_id - }; - - { - // when fetching remote - controller.update_base_branch(*project_id).await.unwrap(); - - // should rebase upstream, and leave uncommited file as is - - let (branches, _) = controller.list_virtual_branches(*project_id).await.unwrap(); - assert_eq!(branches.len(), 1); - assert_eq!(branches[0].id, branch_id); - assert!(!branches[0].active); - assert!(!branches[0].base_current); // TODO: should be true - assert_eq!(branches[0].files.len(), 1); - assert_eq!(branches[0].commits.len(), 1); - assert!(!controller - .can_apply_virtual_branch(*project_id, branch_id) - .await - .unwrap()); // TODO: should be true - } - - { - // applying the branch should produce conflict markers - controller - .apply_virtual_branch(*project_id, branch_id) - .await - .unwrap(); - let (branches, _) = controller.list_virtual_branches(*project_id).await.unwrap(); - assert_eq!(branches.len(), 1); - assert_eq!(branches[0].id, branch_id); - assert!(branches[0].active); - assert!(branches[0].conflicted); - assert!(branches[0].base_current); - assert_eq!(branches[0].files.len(), 1); - assert_eq!(branches[0].commits.len(), 1); - assert_eq!( - std::fs::read_to_string(repository.path().join("file.txt")).unwrap(), - "<<<<<<< ours\nfix conflict\n=======\nsecond\n>>>>>>> theirs\n" - ); - } - } - - #[tokio::test] - async fn commited_conflict_pushed_fixed_with_more_work() { - let Test { - repository, - project_id, - controller, - .. - } = &Test::default(); - - // make sure we have an undiscovered commit in the remote branch - { - fs::write(repository.path().join("file.txt"), "first").unwrap(); - let first_commit_oid = repository.commit_all("first"); - fs::write(repository.path().join("file.txt"), "second").unwrap(); - repository.commit_all("second"); - repository.push(); - repository.reset_hard(Some(first_commit_oid)); - } - - controller - .set_base_branch(*project_id, &"refs/remotes/origin/master".parse().unwrap()) - .await - .unwrap(); - - let branch_id = { - // make a branch with a commit that conflicts with upstream, and work that fixes - // that conflict - let branch_id = controller - .create_virtual_branch(*project_id, &branch::BranchCreateRequest::default()) - .await - .unwrap(); - - fs::write(repository.path().join("file.txt"), "conflict").unwrap(); - controller - .create_commit(*project_id, branch_id, "conflicting commit", None, false) - .await - .unwrap(); - - fs::write(repository.path().join("file.txt"), "fix conflict").unwrap(); - - controller - .unapply_virtual_branch(*project_id, branch_id) - .await - .unwrap(); - - branch_id - }; - - { - // when fetching remote - controller.update_base_branch(*project_id).await.unwrap(); - - // should not touch the branch - - let (branches, _) = controller.list_virtual_branches(*project_id).await.unwrap(); - assert_eq!(branches.len(), 1); - assert_eq!(branches[0].id, branch_id); - assert!(!branches[0].active); - assert!(!branches[0].base_current); - assert_eq!(branches[0].commits.len(), 1); - assert_eq!(branches[0].files.len(), 1); - assert!(!controller - .can_apply_virtual_branch(*project_id, branch_id) - .await - .unwrap()); - } - - { - // applying the branch should produce conflict markers - controller - .apply_virtual_branch(*project_id, branch_id) - .await - .unwrap(); - let (branches, _) = controller.list_virtual_branches(*project_id).await.unwrap(); - assert_eq!(branches.len(), 1); - assert_eq!(branches[0].id, branch_id); - assert!(branches[0].active); - assert!(branches[0].conflicted); - assert!(branches[0].base_current); - assert_eq!(branches[0].files.len(), 1); - assert_eq!(branches[0].commits.len(), 1); - assert_eq!( - std::fs::read_to_string(repository.path().join("file.txt")).unwrap(), - "<<<<<<< ours\nfix conflict\n=======\nsecond\n>>>>>>> theirs\n" - ); - } - } - - #[tokio::test] - async fn no_conflicts() { - let Test { - repository, - project_id, - controller, - .. - } = &Test::default(); - - // make sure we have an undiscovered commit in the remote branch - { - fs::write(repository.path().join("file.txt"), "first").unwrap(); - let first_commit_oid = repository.commit_all("first"); - fs::write(repository.path().join("file.txt"), "second").unwrap(); - repository.commit_all("second"); - repository.push(); - repository.reset_hard(Some(first_commit_oid)); - } - - controller - .set_base_branch(*project_id, &"refs/remotes/origin/master".parse().unwrap()) - .await - .unwrap(); - - let branch_id = { - // make a branch that conflicts with the remote branch, but doesn't know about it yet - let branch_id = controller - .create_virtual_branch(*project_id, &branch::BranchCreateRequest::default()) - .await - .unwrap(); - - fs::write(repository.path().join("file2.txt"), "no conflict").unwrap(); - - controller - .create_commit( - *project_id, - branch_id, - "non conflicting commit", - None, - false, - ) - .await - .unwrap(); - - fs::write(repository.path().join("file2.txt"), "still no conflicts").unwrap(); - - controller - .unapply_virtual_branch(*project_id, branch_id) - .await - .unwrap(); - - branch_id - }; - - { - // fetching remote - controller.update_base_branch(*project_id).await.unwrap(); - - // should update branch base - - let (branches, _) = controller.list_virtual_branches(*project_id).await.unwrap(); - assert_eq!(branches.len(), 1); - assert_eq!(branches[0].id, branch_id); - assert!(!branches[0].active); - assert!(branches[0].base_current); - assert_eq!(branches[0].files.len(), 1); - assert_eq!(branches[0].commits.len(), 1); - assert!(branches[0].upstream.is_none()); - assert!(controller - .can_apply_virtual_branch(*project_id, branch_id) - .await - .unwrap()); - } - - { - // applying the branch should produce conflict markers - controller - .apply_virtual_branch(*project_id, branch_id) - .await - .unwrap(); - let (branches, _) = controller.list_virtual_branches(*project_id).await.unwrap(); - assert_eq!(branches.len(), 1); - assert_eq!(branches[0].id, branch_id); - assert!(branches[0].active); - assert!(!branches[0].conflicted); - assert!(branches[0].base_current); - assert_eq!(branches[0].files.len(), 1); - assert_eq!(branches[0].commits.len(), 1); - assert_eq!( - std::fs::read_to_string(repository.path().join("file.txt")).unwrap(), - "second" - ); - } - } - - #[tokio::test] - async fn integrated_commit_plus_work() { - let Test { - repository, - project_id, - controller, - .. - } = &Test::default(); - - { - fs::write(repository.path().join("file.txt"), "first").unwrap(); - repository.commit_all("first"); - repository.push(); - } - - controller - .set_base_branch(*project_id, &"refs/remotes/origin/master".parse().unwrap()) - .await - .unwrap(); - - let branch_id = { - // make a branch that conflicts with the remote branch, but doesn't know about it yet - let branch_id = controller - .create_virtual_branch(*project_id, &branch::BranchCreateRequest::default()) - .await - .unwrap(); - - fs::write(repository.path().join("file.txt"), "second").unwrap(); - controller - .create_commit(*project_id, branch_id, "second", None, false) - .await - .unwrap(); - - // more local work in the same branch - fs::write(repository.path().join("file2.txt"), "other").unwrap(); - - controller - .push_virtual_branch(*project_id, branch_id, false, None) - .await - .unwrap(); - - { - // merge branch upstream - let branch = controller - .list_virtual_branches(*project_id) - .await - .unwrap() - .0 - .into_iter() - .find(|b| b.id == branch_id) - .unwrap(); - - repository.merge(&branch.upstream.as_ref().unwrap().name); - repository.fetch(); - } - - controller - .unapply_virtual_branch(*project_id, branch_id) - .await - .unwrap(); - branch_id - }; - - { - // fetch remote - controller.update_base_branch(*project_id).await.unwrap(); - - // should remove integrated commit, but leave work - - let (branches, _) = controller.list_virtual_branches(*project_id).await.unwrap(); - assert_eq!(branches.len(), 1); - assert_eq!(branches[0].id, branch_id); - assert!(!branches[0].active); - assert!(branches[0].base_current); - assert_eq!(branches[0].files.len(), 1); - assert_eq!(branches[0].commits.len(), 0); - assert!(branches[0].upstream.is_none()); - assert!(controller - .can_apply_virtual_branch(*project_id, branch_id) - .await - .unwrap()); - } - - { - // applying the branch should produce conflict markers - controller - .apply_virtual_branch(*project_id, branch_id) - .await - .unwrap(); - let (branches, _) = controller.list_virtual_branches(*project_id).await.unwrap(); - assert_eq!(branches.len(), 1); - assert_eq!(branches[0].id, branch_id); - assert!(branches[0].active); - assert!(!branches[0].conflicted); - assert!(branches[0].base_current); - assert_eq!(branches[0].files.len(), 1); - assert_eq!(branches[0].commits.len(), 0); - assert_eq!( - std::fs::read_to_string(repository.path().join("file.txt")).unwrap(), - "second" - ); - assert_eq!( - std::fs::read_to_string(repository.path().join("file2.txt")).unwrap(), - "other" - ); - } - } - - #[tokio::test] - async fn all_integrated() { - let Test { - repository, - project_id, - controller, - .. - } = &Test::default(); - - // make sure we have an undiscovered commit in the remote branch - { - fs::write(repository.path().join("file.txt"), "first").unwrap(); - let first_commit_oid = repository.commit_all("first"); - fs::write(repository.path().join("file.txt"), "second").unwrap(); - repository.commit_all("second"); - repository.push(); - repository.reset_hard(Some(first_commit_oid)); - } - - controller - .set_base_branch(*project_id, &"refs/remotes/origin/master".parse().unwrap()) - .await - .unwrap(); - - { - // make a branch that conflicts with the remote branch, but doesn't know about it yet - let branch_id = controller - .create_virtual_branch(*project_id, &branch::BranchCreateRequest::default()) - .await - .unwrap(); - - fs::write(repository.path().join("file.txt"), "second").unwrap(); - - controller - .create_commit(*project_id, branch_id, "second", None, false) - .await - .unwrap(); - - controller - .unapply_virtual_branch(*project_id, branch_id) - .await - .unwrap(); - }; - - { - // fetch remote - controller.update_base_branch(*project_id).await.unwrap(); - - // should remove identical branch - - let (branches, _) = controller.list_virtual_branches(*project_id).await.unwrap(); - assert_eq!(branches.len(), 0); - } - } - - #[tokio::test] - async fn integrate_work_while_being_behind() { - let Test { - repository, - project_id, - controller, - .. - } = &Test::default(); - - // make sure we have an undiscovered commit in the remote branch - { - fs::write(repository.path().join("file.txt"), "first").unwrap(); - let first_commit_oid = repository.commit_all("first"); - fs::write(repository.path().join("file.txt"), "second").unwrap(); - repository.commit_all("second"); - repository.push(); - repository.reset_hard(Some(first_commit_oid)); - } - - controller - .set_base_branch(*project_id, &"refs/remotes/origin/master".parse().unwrap()) - .await - .unwrap(); - - let branch_id = controller - .create_virtual_branch(*project_id, &branch::BranchCreateRequest::default()) - .await - .unwrap(); - - { - // open pr - fs::write(repository.path().join("file2.txt"), "new file").unwrap(); - controller - .create_commit(*project_id, branch_id, "second", None, false) - .await - .unwrap(); - controller - .push_virtual_branch(*project_id, branch_id, false, None) - .await - .unwrap(); - } - - controller - .unapply_virtual_branch(*project_id, branch_id) - .await - .unwrap(); - - { - // merge pr - let branch = controller - .list_virtual_branches(*project_id) - .await - .unwrap() - .0[0] - .clone(); - repository.merge(&branch.upstream.as_ref().unwrap().name); - repository.fetch(); - } - - { - // fetch remote - controller.update_base_branch(*project_id).await.unwrap(); - - // just removes integrated branch - let (branches, _) = controller.list_virtual_branches(*project_id).await.unwrap(); - assert_eq!(branches.len(), 0); - } - } -} - mod applied_branch { use super::*; @@ -793,51 +28,40 @@ mod applied_branch { .await .unwrap(); - let branch_id = { + { // make a branch that conflicts with the remote branch, but doesn't know about it yet - let branch_id = controller + controller .create_virtual_branch(*project_id, &branch::BranchCreateRequest::default()) .await .unwrap(); fs::write(repository.path().join("file.txt"), "conflict").unwrap(); + } - branch_id - }; - - { + let unapplied_branch = { // fetch remote - controller.update_base_branch(*project_id).await.unwrap(); + let unapplied_branches = controller.update_base_branch(*project_id).await.unwrap(); + assert_eq!(unapplied_branches.len(), 1); // should stash conflicting branch let (branches, _) = controller.list_virtual_branches(*project_id).await.unwrap(); - assert_eq!(branches.len(), 1); - assert_eq!(branches[0].id, branch_id); - assert!(!branches[0].active); - assert!(!branches[0].base_current); - assert_eq!(branches[0].files.len(), 1); - assert_eq!(branches[0].commits.len(), 0); - assert!(!controller - .can_apply_virtual_branch(*project_id, branch_id) - .await - .unwrap()); - } + assert_eq!(branches.len(), 0); + + git::Refname::from_str(unapplied_branches[0].as_str()).unwrap() + }; { // applying the branch should produce conflict markers controller - .apply_virtual_branch(*project_id, branch_id) + .create_virtual_branch_from_branch(*project_id, &unapplied_branch) .await .unwrap(); let (branches, _) = controller.list_virtual_branches(*project_id).await.unwrap(); assert_eq!(branches.len(), 1); - assert_eq!(branches[0].id, branch_id); - assert!(branches[0].active); assert!(branches[0].conflicted); assert!(branches[0].base_current); assert_eq!(branches[0].files.len(), 1); - assert_eq!(branches[0].commits.len(), 0); assert_eq!( std::fs::read_to_string(repository.path().join("file.txt")).unwrap(), "<<<<<<< ours\nconflict\n=======\nsecond\n>>>>>>> theirs\n" @@ -869,7 +93,7 @@ mod applied_branch { .await .unwrap(); - let branch_id = { + { // make a branch with a commit that conflicts with upstream, and work that fixes // that conflict let branch_id = controller @@ -882,43 +106,33 @@ mod applied_branch { .create_commit(*project_id, branch_id, "conflicting commit", None, false) .await .unwrap(); + } - branch_id - }; - - { + let unapplied_branch = { // when fetching remote - controller.update_base_branch(*project_id).await.unwrap(); + let unapplied_branches = controller.update_base_branch(*project_id).await.unwrap(); + assert_eq!(unapplied_branches.len(), 1); // should stash the branch. let (branches, _) = controller.list_virtual_branches(*project_id).await.unwrap(); - assert_eq!(branches.len(), 1); - assert_eq!(branches[0].id, branch_id); - assert!(!branches[0].active); - assert!(!branches[0].base_current); - assert_eq!(branches[0].files.len(), 0); - assert_eq!(branches[0].commits.len(), 1); - assert!(!controller - .can_apply_virtual_branch(*project_id, branch_id) - .await - .unwrap()); - } + assert_eq!(branches.len(), 0); + + git::Refname::from_str(unapplied_branches[0].as_str()).unwrap() + }; { // applying the branch should produce conflict markers controller - .apply_virtual_branch(*project_id, branch_id) + .create_virtual_branch_from_branch(*project_id, &unapplied_branch) .await .unwrap(); let (branches, _) = controller.list_virtual_branches(*project_id).await.unwrap(); assert_eq!(branches.len(), 1); - assert_eq!(branches[0].id, branch_id); - assert!(branches[0].active); assert!(branches[0].conflicted); assert!(branches[0].base_current); assert_eq!(branches[0].files.len(), 1); - assert_eq!(branches[0].commits.len(), 1); + assert_eq!(branches[0].commits.len(), 2); assert_eq!( std::fs::read_to_string(repository.path().join("file.txt")).unwrap(), "<<<<<<< ours\nconflict\n=======\nsecond\n>>>>>>> theirs\n" @@ -950,7 +164,7 @@ mod applied_branch { .await .unwrap(); - let branch_id = { + { // make a branch with a commit that conflicts with upstream, and work that fixes // that conflict let branch_id = controller @@ -968,43 +182,33 @@ mod applied_branch { .push_virtual_branch(*project_id, branch_id, false, None) .await .unwrap(); + } - branch_id - }; - - { + let unapplied_branch = { // when fetching remote - controller.update_base_branch(*project_id).await.unwrap(); + let unapplied_branches = controller.update_base_branch(*project_id).await.unwrap(); + assert_eq!(unapplied_branches.len(), 1); // should stash the branch. let (branches, _) = controller.list_virtual_branches(*project_id).await.unwrap(); - assert_eq!(branches.len(), 1); - assert_eq!(branches[0].id, branch_id); - assert!(!branches[0].active); - assert!(!branches[0].base_current); - assert_eq!(branches[0].files.len(), 0); - assert_eq!(branches[0].commits.len(), 1); - assert!(!controller - .can_apply_virtual_branch(*project_id, branch_id) - .await - .unwrap()); - } + assert_eq!(branches.len(), 0); + + git::Refname::from_str(unapplied_branches[0].as_str()).unwrap() + }; { // applying the branch should produce conflict markers controller - .apply_virtual_branch(*project_id, branch_id) + .create_virtual_branch_from_branch(*project_id, &unapplied_branch) .await .unwrap(); let (branches, _) = controller.list_virtual_branches(*project_id).await.unwrap(); assert_eq!(branches.len(), 1); - assert_eq!(branches[0].id, branch_id); - assert!(branches[0].active); assert!(branches[0].conflicted); assert!(branches[0].base_current); assert_eq!(branches[0].files.len(), 1); - assert_eq!(branches[0].commits.len(), 1); + assert_eq!(branches[0].commits.len(), 2); assert_eq!( std::fs::read_to_string(repository.path().join("file.txt")).unwrap(), "<<<<<<< ours\nconflict\n=======\nsecond\n>>>>>>> theirs\n" @@ -1036,7 +240,7 @@ mod applied_branch { .await .unwrap(); - let branch_id = { + { // make a branch with a commit that conflicts with upstream, and work that fixes // that conflict let branch_id = controller @@ -1051,43 +255,33 @@ mod applied_branch { .unwrap(); fs::write(repository.path().join("file.txt"), "fix conflict").unwrap(); + } - branch_id - }; - - { + let unapplied_branch = { // when fetching remote - controller.update_base_branch(*project_id).await.unwrap(); + let unapplied_branches = controller.update_base_branch(*project_id).await.unwrap(); + assert_eq!(unapplied_branches.len(), 1); // should rebase upstream, and leave uncommited file as is let (branches, _) = controller.list_virtual_branches(*project_id).await.unwrap(); - assert_eq!(branches.len(), 1); - assert_eq!(branches[0].id, branch_id); - assert!(!branches[0].active); - assert!(!branches[0].base_current); // TODO: should be true - assert_eq!(branches[0].files.len(), 1); - assert_eq!(branches[0].commits.len(), 1); - assert!(!controller - .can_apply_virtual_branch(*project_id, branch_id) - .await - .unwrap()); // TODO: should be true - } + assert_eq!(branches.len(), 0); + + git::Refname::from_str(unapplied_branches[0].as_str()).unwrap() + }; { // applying the branch should produce conflict markers controller - .apply_virtual_branch(*project_id, branch_id) + .create_virtual_branch_from_branch(*project_id, &unapplied_branch) .await .unwrap(); let (branches, _) = controller.list_virtual_branches(*project_id).await.unwrap(); assert_eq!(branches.len(), 1); - assert_eq!(branches[0].id, branch_id); - assert!(branches[0].active); assert!(branches[0].conflicted); assert!(branches[0].base_current); assert_eq!(branches[0].files.len(), 1); - assert_eq!(branches[0].commits.len(), 1); + assert_eq!(branches[0].commits.len(), 2); assert_eq!( std::fs::read_to_string(repository.path().join("file.txt")).unwrap(), "<<<<<<< ours\nfix conflict\n=======\nsecond\n>>>>>>> theirs\n" @@ -1119,7 +313,7 @@ mod applied_branch { .await .unwrap(); - let branch_id = { + { // make a branch with a commit that conflicts with upstream, and work that fixes // that conflict let branch_id = controller @@ -1134,43 +328,33 @@ mod applied_branch { .unwrap(); fs::write(repository.path().join("file.txt"), "fix conflict").unwrap(); + } - branch_id - }; - - { + let unapplied_branch = { // when fetching remote - controller.update_base_branch(*project_id).await.unwrap(); + let unapplied_branches = controller.update_base_branch(*project_id).await.unwrap(); + assert_eq!(unapplied_branches.len(), 1); // should merge upstream, and leave uncommited file as is. let (branches, _) = controller.list_virtual_branches(*project_id).await.unwrap(); - assert_eq!(branches.len(), 1); - assert_eq!(branches[0].id, branch_id); - assert!(!branches[0].active); - assert!(!branches[0].base_current); // TODO: should be true - assert_eq!(branches[0].commits.len(), 1); // TODO: should be 2 - assert_eq!(branches[0].files.len(), 1); - assert!(!controller - .can_apply_virtual_branch(*project_id, branch_id) - .await - .unwrap()); // TODO: should be true - } + assert_eq!(branches.len(), 0); + + git::Refname::from_str(unapplied_branches[0].as_str()).unwrap() + }; { // applying the branch should produce conflict markers controller - .apply_virtual_branch(*project_id, branch_id) + .create_virtual_branch_from_branch(*project_id, &unapplied_branch) .await .unwrap(); let (branches, _) = controller.list_virtual_branches(*project_id).await.unwrap(); assert_eq!(branches.len(), 1); - assert_eq!(branches[0].id, branch_id); - assert!(branches[0].active); assert!(branches[0].conflicted); assert!(branches[0].base_current); assert_eq!(branches[0].files.len(), 1); - assert_eq!(branches[0].commits.len(), 1); + assert_eq!(branches[0].commits.len(), 2); assert_eq!( std::fs::read_to_string(repository.path().join("file.txt")).unwrap(), "<<<<<<< ours\nfix conflict\n=======\nsecond\n>>>>>>> theirs\n" @@ -1253,10 +437,6 @@ mod applied_branch { assert_eq!(branches[0].commits.len(), 1); assert!(!branches[0].commits[0].is_remote); assert!(!branches[0].commits[0].is_integrated); - assert!(controller - .can_apply_virtual_branch(*project_id, branch_id) - .await - .unwrap()); } } @@ -1336,10 +516,6 @@ mod applied_branch { assert!(!branches[0].commits[0].is_integrated); assert!(branches[0].commits[1].is_remote); assert!(!branches[0].commits[1].is_integrated); - assert!(controller - .can_apply_virtual_branch(*project_id, branch_id) - .await - .unwrap()); } } } @@ -1399,25 +575,7 @@ mod applied_branch { assert!(branches[0].base_current); assert_eq!(branches[0].files.len(), 1); assert_eq!(branches[0].commits.len(), 1); - assert!(controller - .can_apply_virtual_branch(*project_id, branch_id) - .await - .unwrap()); - } - { - controller - .apply_virtual_branch(*project_id, branch_id) - .await - .unwrap(); - let (branches, _) = controller.list_virtual_branches(*project_id).await.unwrap(); - assert_eq!(branches.len(), 1); - assert_eq!(branches[0].id, branch_id); - assert!(branches[0].active); - assert!(!branches[0].conflicted); - assert!(branches[0].base_current); - assert_eq!(branches[0].files.len(), 1); - assert_eq!(branches[0].commits.len(), 1); assert_eq!( std::fs::read_to_string(repository.path().join("file.txt")).unwrap(), "second" @@ -1500,26 +658,7 @@ mod applied_branch { assert!(branches[0].base_current); assert_eq!(branches[0].files.len(), 1); assert_eq!(branches[0].commits.len(), 0); - assert!(controller - .can_apply_virtual_branch(*project_id, branch_id) - .await - .unwrap()); - } - { - // applying the branch should produce conflict markers - controller - .apply_virtual_branch(*project_id, branch_id) - .await - .unwrap(); - let (branches, _) = controller.list_virtual_branches(*project_id).await.unwrap(); - assert_eq!(branches.len(), 1); - assert_eq!(branches[0].id, branch_id); - assert!(branches[0].active); - assert!(!branches[0].conflicted); - assert!(branches[0].base_current); - assert_eq!(branches[0].files.len(), 1); - assert_eq!(branches[0].commits.len(), 0); assert_eq!( std::fs::read_to_string(repository.path().join("file.txt")).unwrap(), "second" @@ -1610,22 +749,28 @@ mod applied_branch { repository.fetch(); - { - controller.update_base_branch(*project_id).await.unwrap(); + let unapplied_refname = { + let unapplied_refnames = controller.update_base_branch(*project_id).await.unwrap(); + assert_eq!(unapplied_refnames.len(), 1); // removes integrated commit, leaves non commited work as is let (branches, _) = controller.list_virtual_branches(*project_id).await.unwrap(); - assert_eq!(branches.len(), 1); - assert_eq!(branches[0].id, branch_id); - assert!(!branches[0].active); - assert!(branches[0].commits.is_empty()); - assert!(!branches[0].files.is_empty()); - } + assert_eq!(branches.len(), 0); + assert_eq!( + fs::read_to_string(repository.path().join("file.txt")).unwrap(), + "1\n2\n3\n4\n5\n6\n17\n8\n19\n10\n11\n12\n" + ); + + unapplied_refnames[0].clone() + }; { controller - .apply_virtual_branch(*project_id, branch_id) + .create_virtual_branch_from_branch( + *project_id, + &git::Refname::from_str(unapplied_refname.as_str()).unwrap(), + ) .await .unwrap(); @@ -1640,7 +785,6 @@ mod applied_branch { branches[0].files[0].hunks[0].diff, "@@ -4,7 +4,11 @@\n 4\n 5\n 6\n-7\n+<<<<<<< ours\n+77\n+=======\n+17\n+>>>>>>> theirs\n 8\n 19\n 10\n" ); - assert_eq!(branches[0].commits.len(), 0); } } @@ -1717,21 +861,6 @@ mod applied_branch { assert!(branches[0].upstream.is_none()); assert_eq!(branches[0].files.len(), 1); } - - { - controller - .apply_virtual_branch(*project_id, branch_id) - .await - .unwrap(); - - let (branches, _) = controller.list_virtual_branches(*project_id).await.unwrap(); - assert_eq!(branches.len(), 1); - assert!(branches[0].active); - assert!(!branches[0].conflicted); - assert!(branches[0].base_current); - assert_eq!(branches[0].files.len(), 1); - assert_eq!(branches[0].commits.len(), 0); // no merge commit - } } #[tokio::test] @@ -1797,22 +926,7 @@ mod applied_branch { assert!(branches[0].active); assert!(branches[0].commits.is_empty()); assert!(branches[0].upstream.is_none()); - assert!(!branches[0].files.is_empty()); - } - - { - controller - .apply_virtual_branch(*project_id, branch_id) - .await - .unwrap(); - - let (branches, _) = controller.list_virtual_branches(*project_id).await.unwrap(); - assert_eq!(branches.len(), 1); - assert!(branches[0].active); - assert!(!branches[0].conflicted); - assert!(branches[0].base_current); assert_eq!(branches[0].files.len(), 1); - assert_eq!(branches[0].commits.len(), 0); } } diff --git a/crates/gitbutler-core/tests/virtual_branches/mod.rs b/crates/gitbutler-core/tests/virtual_branches/mod.rs index e8fbbba419..1df3a8286f 100644 --- a/crates/gitbutler-core/tests/virtual_branches/mod.rs +++ b/crates/gitbutler-core/tests/virtual_branches/mod.rs @@ -15,14 +15,15 @@ use std::{ use anyhow::{Context, Result}; use git2::TreeEntry; use gitbutler_core::{ - git::{self, CommitExt, RepositoryExt}, + git::{self, CommitExt, CommitHeadersV2, RepositoryExt}, virtual_branches::{ - self, apply_branch, + self, branch::{BranchCreateRequest, BranchOwnershipClaims, BranchUpdateRequest}, - commit, create_virtual_branch, integrate_upstream_commits, + commit, create_virtual_branch, create_virtual_branch_from_branch, + integrate_upstream_commits, integration::verify_branch, - is_remote_branch_mergeable, is_virtual_branch_mergeable, list_remote_branches, - unapply_ownership, update_branch, + is_remote_branch_mergeable, list_remote_branches, list_virtual_branches, unapply_ownership, + update_branch, }, }; use pretty_assertions::assert_eq; @@ -726,7 +727,12 @@ fn commit_id_can_be_generated_or_specified() -> Result<()> { .expect("failed to get head"), ) .expect("failed to find commit")], - Some("my-change-id"), + // The change ID should always be generated by calling CommitHeadersV2::new + Some(CommitHeadersV2 { + change_id: "my-change-id".to_string(), + is_unapplied_header_commit: false, + vbranch_name: None, + }), ) .expect("failed to commit"); @@ -1077,7 +1083,11 @@ fn unapply_branch() -> Result<()> { assert_eq!(branch.files.len(), 1); assert!(branch.active); - virtual_branches::unapply_branch(project_repository, branch1_id)?; + let real_branch = virtual_branches::convert_to_real_branch( + project_repository, + branch1_id, + Default::default(), + )?; let contents = std::fs::read(Path::new(&project.path).join(file_path))?; assert_eq!("line1\nline2\nline3\nline4\n", String::from_utf8(contents)?); @@ -1085,11 +1095,13 @@ fn unapply_branch() -> Result<()> { assert_eq!("line5\nline6\n", String::from_utf8(contents)?); let (branches, _) = virtual_branches::list_virtual_branches(project_repository)?; - let branch = &branches.iter().find(|b| b.id == branch1_id).unwrap(); - assert_eq!(branch.files.len(), 1); - assert!(!branch.active); + assert!(!branches.iter().any(|b| b.id == branch1_id)); - apply_branch(project_repository, branch1_id, None)?; + let branch1_id = virtual_branches::create_virtual_branch_from_branch( + project_repository, + &git::Refname::try_from(&real_branch)?, + None, + )?; let contents = std::fs::read(Path::new(&project.path).join(file_path))?; assert_eq!( "line1\nline2\nline3\nline4\nbranch1\n", @@ -1153,20 +1165,43 @@ fn apply_unapply_added_deleted_files() -> Result<()> { }, )?; - virtual_branches::unapply_branch(project_repository, branch2_id)?; + list_virtual_branches(project_repository).unwrap(); + + let real_branch_2 = virtual_branches::convert_to_real_branch( + project_repository, + branch2_id, + Default::default(), + )?; + // check that file2 is back let contents = std::fs::read(Path::new(&project.path).join(file_path2))?; assert_eq!("file2\n", String::from_utf8(contents)?); - virtual_branches::unapply_branch(project_repository, branch3_id)?; + let real_branch_3 = virtual_branches::convert_to_real_branch( + project_repository, + branch3_id, + Default::default(), + )?; // check that file3 is gone assert!(!Path::new(&project.path).join(file_path3).exists()); - apply_branch(project_repository, branch2_id, None)?; + create_virtual_branch_from_branch( + project_repository, + &git::Refname::try_from(&real_branch_2).unwrap(), + None, + ) + .unwrap(); + // check that file2 is gone assert!(!Path::new(&project.path).join(file_path2).exists()); - apply_branch(project_repository, branch3_id, None)?; + create_virtual_branch_from_branch( + project_repository, + &git::Refname::try_from(&real_branch_3).unwrap(), + None, + ) + .unwrap(); + // check that file3 is back let contents = std::fs::read(Path::new(&project.path).join(file_path3))?; assert_eq!("file3\n", String::from_utf8(contents)?); @@ -1174,6 +1209,7 @@ fn apply_unapply_added_deleted_files() -> Result<()> { Ok(()) } +// Verifies that we are able to detect when a remote branch is conflicting with the current applied branches. #[test] fn detect_mergeable_branch() -> Result<()> { let suite = Suite::default(); @@ -1218,8 +1254,8 @@ fn detect_mergeable_branch() -> Result<()> { .expect("failed to update branch"); // unapply both branches and create some conflicting ones - virtual_branches::unapply_branch(project_repository, branch1_id)?; - virtual_branches::unapply_branch(project_repository, branch2_id)?; + virtual_branches::convert_to_real_branch(project_repository, branch1_id, Default::default())?; + virtual_branches::convert_to_real_branch(project_repository, branch2_id, Default::default())?; project_repository.repo().set_head("refs/heads/master")?; project_repository @@ -1293,17 +1329,6 @@ fn detect_mergeable_branch() -> Result<()> { }; vb_state.set_branch(branch4.clone())?; - let (branches, _) = virtual_branches::list_virtual_branches(project_repository)?; - assert_eq!(branches.len(), 4); - - let branch1 = &branches.iter().find(|b| b.id == branch1_id).unwrap(); - assert!(!branch1.active); - assert!(!is_virtual_branch_mergeable(project_repository, branch1.id).unwrap()); - - let branch2 = &branches.iter().find(|b| b.id == branch2_id).unwrap(); - assert!(!branch2.active); - assert!(is_virtual_branch_mergeable(project_repository, branch2.id).unwrap()); - let remotes = list_remote_branches(project_repository).expect("failed to list remotes"); let _remote1 = &remotes .iter() diff --git a/crates/gitbutler-tauri/src/main.rs b/crates/gitbutler-tauri/src/main.rs index 77892c1c56..1ba536ac41 100644 --- a/crates/gitbutler-tauri/src/main.rs +++ b/crates/gitbutler-tauri/src/main.rs @@ -185,17 +185,14 @@ fn main() { virtual_branches::commands::integrate_upstream_commits, virtual_branches::commands::update_virtual_branch, virtual_branches::commands::delete_virtual_branch, - virtual_branches::commands::apply_branch, - virtual_branches::commands::unapply_branch, + virtual_branches::commands::convert_to_real_branch, virtual_branches::commands::unapply_ownership, virtual_branches::commands::reset_files, virtual_branches::commands::push_virtual_branch, virtual_branches::commands::create_virtual_branch_from_branch, - virtual_branches::commands::can_apply_virtual_branch, virtual_branches::commands::can_apply_remote_branch, virtual_branches::commands::list_remote_commit_files, virtual_branches::commands::reset_virtual_branch, - virtual_branches::commands::cherry_pick_onto_virtual_branch, virtual_branches::commands::amend_virtual_branch, virtual_branches::commands::move_commit_file, virtual_branches::commands::undo_commit, diff --git a/crates/gitbutler-tauri/src/virtual_branches.rs b/crates/gitbutler-tauri/src/virtual_branches.rs index 2f3ce1f08e..ab5de4d315 100644 --- a/crates/gitbutler-tauri/src/virtual_branches.rs +++ b/crates/gitbutler-tauri/src/virtual_branches.rs @@ -4,12 +4,14 @@ pub mod commands { use gitbutler_core::{ assets, error::Code, - git, projects, - projects::ProjectId, + git, + projects::{self, ProjectId}, + types::ReferenceName, virtual_branches::{ branch::{self, BranchId, BranchOwnershipClaims}, controller::Controller, - BaseBranch, RemoteBranch, RemoteBranchData, RemoteBranchFile, VirtualBranches, + BaseBranch, NameConflitResolution, RemoteBranch, RemoteBranchData, RemoteBranchFile, + VirtualBranches, }, }; use tauri::{AppHandle, Manager}; @@ -153,7 +155,7 @@ pub mod commands { pub async fn update_base_branch( handle: AppHandle, project_id: ProjectId, - ) -> Result, Error> { + ) -> Result, Error> { let unapplied_branches = handle .state::() .update_base_branch(project_id) @@ -195,29 +197,15 @@ pub mod commands { #[tauri::command(async)] #[instrument(skip(handle), err(Debug))] - pub async fn apply_branch( + pub async fn convert_to_real_branch( handle: AppHandle, project_id: ProjectId, branch: BranchId, + name_conflict_resolution: NameConflitResolution, ) -> Result<(), Error> { handle .state::() - .apply_virtual_branch(project_id, branch) - .await?; - emit_vbranches(&handle, project_id).await; - Ok(()) - } - - #[tauri::command(async)] - #[instrument(skip(handle), err(Debug))] - pub async fn unapply_branch( - handle: AppHandle, - project_id: ProjectId, - branch: BranchId, - ) -> Result<(), Error> { - handle - .state::() - .unapply_virtual_branch(project_id, branch) + .convert_to_real_branch(project_id, branch, name_conflict_resolution) .await?; emit_vbranches(&handle, project_id).await; Ok(()) @@ -275,20 +263,6 @@ pub mod commands { Ok(()) } - #[tauri::command(async)] - #[instrument(skip(handle), err(Debug))] - pub async fn can_apply_virtual_branch( - handle: AppHandle, - project_id: ProjectId, - branch_id: BranchId, - ) -> Result { - handle - .state::() - .can_apply_virtual_branch(project_id, branch_id) - .await - .map_err(Into::into) - } - #[tauri::command(async)] #[instrument(skip(handle), err(Debug))] pub async fn can_apply_remote_branch( @@ -334,23 +308,6 @@ pub mod commands { Ok(()) } - #[tauri::command(async)] - #[instrument(skip(handle), err(Debug))] - pub async fn cherry_pick_onto_virtual_branch( - handle: AppHandle, - project_id: ProjectId, - branch_id: BranchId, - target_commit_oid: String, - ) -> Result, Error> { - let target_commit_oid = git2::Oid::from_str(&target_commit_oid).map_err(|e| anyhow!(e))?; - let oid = handle - .state::() - .cherry_pick(project_id, branch_id, target_commit_oid) - .await?; - emit_vbranches(&handle, project_id).await; - Ok(oid.map(|o| o.to_string())) - } - #[tauri::command(async)] #[instrument(skip(handle), err(Debug))] pub async fn amend_virtual_branch(