Merge pull request #4717 from gitbutlerapp/Rebase-revolution

Rebase revolution
This commit is contained in:
Caleb Owens 2024-08-22 18:28:49 +02:00 committed by GitHub
commit ec7a38f538
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
28 changed files with 621 additions and 122 deletions

1
Cargo.lock generated
View File

@ -2422,6 +2422,7 @@ dependencies = [
"thiserror",
"tokio",
"tracing",
"uuid",
]
[[package]]

View File

@ -29,6 +29,14 @@ export class Project {
use_new_locking!: boolean;
ignore_project_semaphore!: boolean;
private succeeding_rebases!: boolean;
get succeedingRebases() {
return this.succeeding_rebases;
}
set succeedingRebases(value) {
this.succeeding_rebases = value;
}
// Produced just for the frontend to determine if the project is open in any window.
is_open!: boolean;

View File

@ -10,7 +10,12 @@ class BranchDragActions {
) {}
acceptMoveCommit(data: any) {
return data instanceof DraggableCommit && data.branchId !== this.branch.id && data.isHeadCommit;
return (
data instanceof DraggableCommit &&
data.branchId !== this.branch.id &&
data.isHeadCommit &&
!data.commit.conflicted
);
}
onMoveCommit(data: DraggableCommit) {

View File

@ -4,7 +4,6 @@
import { BaseBranch } from '$lib/baseBranch/baseBranch';
import CommitMessageInput from '$lib/commit/CommitMessageInput.svelte';
import { persistedCommitMessage } from '$lib/config/config';
import { featureEditMode } from '$lib/config/uiFeatureFlags';
import { draggableCommit } from '$lib/dragging/draggable';
import { DraggableCommit, nonDraggable } from '$lib/dragging/draggables';
import BranchFilesList from '$lib/file/BranchFilesList.svelte';
@ -44,8 +43,6 @@
const project = getContext(Project);
const modeService = maybeGetContext(ModeService);
const editModeEnabled = featureEditMode();
const commitStore = createCommitStore(commit);
$: commitStore.set(commit);
@ -135,6 +132,8 @@
modeService!.enterEditMode(commit.id, branch!.refname);
}
$: conflicted = commit instanceof DetailedCommit && commit.conflicted;
</script>
<Modal bind:this={commitMessageModal} width="small">
@ -270,6 +269,21 @@
<span class="commit__subtitle-divider"></span>
{/if}
{#if conflicted}
<div
class="commit__conflicted"
use:tooltip={{
text: 'Conflicted commits must be resolved before they can be ammended or squashed.\n\nPlease resolve conflicts using the "Resolve conflicts" button'
}}
>
<Icon name="warning-small" />
Conflicted
</div>
<span class="commit__subtitle-divider"></span>
{/if}
<button
class="commit__subtitle-btn commit__subtitle-btn_dashed"
on:click|stopPropagation={() => copyToClipboard(commit.id)}
@ -318,17 +332,19 @@
{#if isUndoable}
<div class="commit__actions hide-native-scrollbar">
{#if isUndoable}
<Button
size="tag"
style="ghost"
outline
icon="undo-small"
onclick={(e) => {
currentCommitMessage.set(commit.description);
e.stopPropagation();
undoCommit(commit);
}}>Undo</Button
>
{#if !conflicted}
<Button
size="tag"
style="ghost"
outline
icon="undo-small"
onclick={(e) => {
currentCommitMessage.set(commit.description);
e.stopPropagation();
undoCommit(commit);
}}>Undo</Button
>
{/if}
<Button
size="tag"
style="ghost"
@ -337,8 +353,14 @@
onclick={openCommitMessageModal}>Edit message</Button
>
{/if}
{#if canEdit() && $editModeEnabled}
<Button size="tag" style="ghost" outline onclick={editPatch}>Edit patch</Button>
{#if canEdit() && project.succeedingRebases}
<Button size="tag" style="ghost" outline onclick={editPatch}>
{#if conflicted}
Resolve conflicts
{:else}
Edit patch
{/if}
</Button>
{/if}
</div>
{/if}
@ -394,6 +416,14 @@
}
}
.commit__conflicted {
display: flex;
align-items: center;
gap: 4px;
color: var(--clr-core-err-40);
}
.accent-border-line {
position: absolute;
top: 0;

View File

@ -116,6 +116,8 @@
if (isLast) return 0;
return 0;
}
$: localCommitsConflicted = $localCommits.some((commit) => commit.conflicted);
</script>
{#snippet reorderDropzone(dropzone: ReorderDropzone, yOffsetPx: number)}
@ -225,6 +227,10 @@
kind="solid"
wide
loading={isPushingCommits}
disabled={localCommitsConflicted}
help={localCommitsConflicted
? 'In order to push, please resolve any conflicted commits.'
: undefined}
onclick={async () => {
isPushingCommits = true;
try {

View File

@ -34,13 +34,15 @@ export class CommitDragActions {
if (
data instanceof DraggableHunk &&
data.branchId === this.branch.id &&
data.commitId !== this.commit.id
data.commitId !== this.commit.id &&
!this.commit.conflicted
) {
return true;
} else if (
data instanceof DraggableFile &&
data.branchId === this.branch.id &&
data.commit?.id !== this.commit.id
data.commit?.id !== this.commit.id &&
!this.commit.conflicted
) {
return true;
} else {
@ -79,6 +81,8 @@ export class CommitDragActions {
if (!(data instanceof DraggableCommit)) return false;
if (data.branchId !== this.branch.id) return false;
if (this.commit.conflicted || data.commit.conflicted) return false;
if (data.commit.isParentOf(this.commit)) {
if (data.commit.isIntegrated) return false;
if (data.commit.isRemote && !this.project.ok_with_force_push) return false;

View File

@ -38,7 +38,10 @@
</span>
</p>
<p class="switchrepo__message text-13 text-body">Bla bla bla</p>
<p class="switchrepo__message text-13 text-body">
Please do not make any commits whilst in edit mode. To leave edit mode, use the "save changes"
button.
</p>
<div class="switchrepo__actions">
<Button

View File

@ -15,8 +15,3 @@ export function featureInlineUnifiedDiffs(): Persisted<boolean> {
const key = 'inlineUnifiedDiffs';
return persisted(false, key);
}
export function featureEditMode(): Persisted<boolean> {
const key = 'editMode';
return persisted(false, key);
}

View File

@ -71,6 +71,12 @@
let loading = true;
let signCheckResult = false;
let errorMessage = '';
let succeedingRebases = project.succeedingRebases;
$: {
project.succeedingRebases = succeedingRebases;
projectService.updateProject(project);
}
async function checkSigning() {
checked = true;
@ -308,4 +314,16 @@
<Toggle id="ignoreProjectSemaphore" bind:checked={ignoreProjectSemaphore} />
</svelte:fragment>
</SectionCard>
<SectionCard labelFor="succeedingRebases" orientation="row">
<svelte:fragment slot="title">Edit mode and succeeding rebases</svelte:fragment>
<svelte:fragment slot="caption">
This is an experimental setting which will ensure that rebasing will always succeed,
introduces a mode for editing individual commits, and adds the ability to resolve conflicted
commits.
</svelte:fragment>
<svelte:fragment slot="actions">
<Toggle id="succeedingRebases" bind:checked={succeedingRebases} />
</svelte:fragment>
</SectionCard>
</Section>

View File

@ -172,6 +172,7 @@ export class DetailedCommit {
changeId!: string;
isSigned!: boolean;
relatedTo?: Commit;
conflicted!: boolean;
prev?: DetailedCommit;
next?: DetailedCommit;

View File

@ -2,7 +2,6 @@
import SectionCard from '$lib/components/SectionCard.svelte';
import {
featureBaseBranchSwitching,
featureEditMode,
featureInlineUnifiedDiffs
} from '$lib/config/uiFeatureFlags';
import ContentWrapper from '$lib/settings/ContentWrapper.svelte';
@ -10,7 +9,6 @@
const baseBranchSwitching = featureBaseBranchSwitching();
const inlineUnifiedDiffs = featureInlineUnifiedDiffs();
const editMode = featureEditMode();
</script>
<ContentWrapper title="Experimental features">
@ -47,20 +45,6 @@
/>
</svelte:fragment>
</SectionCard>
<SectionCard labelFor="editMode" orientation="row">
<svelte:fragment slot="title">Edit mode</svelte:fragment>
<svelte:fragment slot="caption">
Provides an "Edit patch" button on each commit which puts you into edit mode. Edit mode checks
out a particular commit so you can make changes to a particular commit, and then have the
child commits automatically rebased on top of the new changes.
<br /><br />
Please note that creating conflicts whilst inside edit mode is currently not supported. This feature
is still experimental and may result in loss of work.
</svelte:fragment>
<svelte:fragment slot="actions">
<Toggle id="editMode" checked={$editMode} on:click={() => ($editMode = !$editMode)} />
</svelte:fragment>
</SectionCard>
</ContentWrapper>
<style>

View File

@ -360,8 +360,7 @@ pub(crate) fn update_base_branch(
let updated_vbranches = get_applied_status(ctx, None)?
.branches
.into_iter()
.map(|(branch, _)| branch)
.map(|mut branch: Branch| -> Result<Option<Branch>> {
.map(|(mut branch, _)| -> Result<Option<Branch>> {
let branch_tree = repo.find_tree(branch.tree)?;
let branch_head_commit = repo.find_commit(branch.head).context(format!(

View File

@ -33,6 +33,7 @@ pub struct VirtualBranchCommit {
pub branch_id: BranchId,
pub change_id: Option<String>,
pub is_signed: bool,
pub conflicted: bool,
}
pub(crate) fn commit_to_vbranch_commit(
@ -68,6 +69,7 @@ pub(crate) fn commit_to_vbranch_commit(
branch_id: branch.id,
change_id: commit.change_id(),
is_signed: commit.is_signed(),
conflicted: commit.is_conflicted(),
};
Ok(commit)

View File

@ -6,6 +6,7 @@ use std::{
use anyhow::{anyhow, Context, Result};
use gitbutler_command_context::CommandContext;
use gitbutler_diff::FileDiff;
use gitbutler_repo::RepositoryExt;
use serde::Serialize;
use crate::{
@ -38,8 +39,12 @@ pub(crate) fn list_remote_commit_files(
}
let parent = commit.parent(0).context("failed to get parent commit")?;
let commit_tree = commit.tree().context("failed to get commit tree")?;
let parent_tree = parent.tree().context("failed to get parent tree")?;
let commit_tree = repository
.find_real_tree(&commit, Default::default())
.context("failed to get commit tree")?;
let parent_tree = repository
.find_real_tree(&parent, Default::default())
.context("failed to get parent tree")?;
let diff_files = gitbutler_diff::trees(repository, &parent_tree, &commit_tree)?;
Ok(diff_files
@ -93,8 +98,13 @@ pub(crate) fn list_virtual_commit_files(
return Ok(vec![]);
}
let parent = commit.parent(0).context("failed to get parent commit")?;
let commit_tree = commit.tree().context("failed to get commit tree")?;
let parent_tree = parent.tree().context("failed to get parent tree")?;
let repository = ctx.repository();
let commit_tree = repository
.find_real_tree(commit, Default::default())
.context("failed to get commit tree")?;
let parent_tree = repository
.find_real_tree(&parent, Default::default())
.context("failed to get parent tree")?;
let diff = gitbutler_diff::trees(ctx.repository(), &parent_tree, &commit_tree)?;
let hunks_by_filepath = virtual_hunks_by_file_diffs(&ctx.project().path, diff);
Ok(virtual_hunks_into_virtual_files(ctx, hunks_by_filepath))

View File

@ -31,7 +31,7 @@ pub(crate) fn get_workspace_head(ctx: &CommandContext) -> Result<git2::Oid> {
let mut virtual_branches: Vec<Branch> = vb_state.list_branches_in_workspace()?;
let target_commit = repo.find_commit(target.sha)?;
let mut workspace_tree = target_commit.tree()?;
let mut workspace_tree = repo.find_real_tree(&target_commit, Default::default())?;
if conflicts::is_conflicting(ctx, None)? {
let merge_parent = conflicts::merge_parent(ctx)?.ok_or(anyhow!("No merge parent"))?;
@ -41,8 +41,10 @@ pub(crate) fn get_workspace_head(ctx: &CommandContext) -> Result<git2::Oid> {
workspace_tree = repo.find_commit(merge_base)?.tree()?;
} else {
for branch in virtual_branches.iter_mut() {
let branch_tree = repo.find_commit(branch.head)?.tree()?;
let merge_tree = repo.find_commit(target.sha)?.tree()?;
let branch_tree = repo.find_commit(branch.head)?;
let branch_tree = repo.find_real_tree(&branch_tree, Default::default())?;
let mut index = repo.merge_trees(&merge_tree, &workspace_tree, &branch_tree, None)?;
if !index.has_conflicts() {

View File

@ -9,6 +9,7 @@ use gitbutler_command_context::CommandContext;
use gitbutler_diff::{diff_files_into_hunks, GitHunk, Hunk, HunkHash};
use gitbutler_operating_modes::assure_open_workspace_mode;
use gitbutler_project::access::WorktreeWritePermission;
use gitbutler_repo::RepositoryExt;
use crate::{
conflicts::RepoConflictsExt,
@ -33,8 +34,7 @@ pub fn get_applied_status(
ctx: &CommandContext,
perm: Option<&mut WorktreeWritePermission>,
) -> Result<VirtualBranchesStatus> {
assure_open_workspace_mode(ctx)
.context("Getting applied status requires open workspace mode")?;
assure_open_workspace_mode(ctx).context("ng applied status requires open workspace mode")?;
let integration_commit = get_workspace_head(ctx)?;
let mut virtual_branches = ctx
.project()
@ -240,7 +240,9 @@ fn compute_locks(
.iter()
.filter_map(|branch| {
let commit = repository.find_commit(branch.head).ok()?;
let tree = commit.tree().ok()?;
let tree = repository
.find_real_tree(&commit, Default::default())
.ok()?;
let diff = repository
.diff_tree_to_tree(Some(&base_tree), Some(&tree), Some(opts))
.ok()?;

View File

@ -9,8 +9,8 @@ use anyhow::{anyhow, bail, Context, Result};
use bstr::ByteSlice;
use git2_hooks::HookResult;
use gitbutler_branch::{
dedup, dedup_fmt, reconcile_claims, Branch, BranchId, BranchOwnershipClaims,
BranchUpdateRequest, OwnershipClaim, Target, VirtualBranchesHandle,
dedup, dedup_fmt, reconcile_claims, signature, Branch, BranchId, BranchOwnershipClaims,
BranchUpdateRequest, OwnershipClaim, SignaturePurpose, Target, VirtualBranchesHandle,
};
use gitbutler_command_context::CommandContext;
use gitbutler_commit::{commit_ext::CommitExt, commit_headers::HasCommitHeaders};
@ -1550,6 +1550,32 @@ pub(crate) fn reorder_commit(
let parent = commit.parent(0).context("failed to find parent")?;
let parent_oid = parent.id();
let repository = ctx.repository();
let tree = repository
.find_tree(branch.tree)
.context("Failed to get branch tree")?;
let author_signature =
signature(SignaturePurpose::Author).context("Failed to get gitbutler signature")?;
let committer_signature =
signature(SignaturePurpose::Committer).context("Failed to get gitbutler signature")?;
let head = repository
.find_commit(branch.head)
.context("Failed to find branch head commit")?;
let tree_commit = repository
.commit(
None,
&author_signature,
&committer_signature,
"Branch commited changes",
&tree,
&[&head],
)
.context("Failed to commit uncommited changes")?;
if offset < 0 {
// move commit up
if branch.head == commit_oid {
@ -1567,12 +1593,8 @@ pub(crate) fn reorder_commit(
let new_head =
cherry_rebase_group(ctx, parent_oid, &mut ids_to_rebase).context("rebase failed")?;
branch.head = new_head;
branch.updated_timestamp_ms = gitbutler_time::time::now_ms();
vb_state.set_branch(branch.clone())?;
crate::integration::update_gitbutler_integration(&vb_state, ctx)
.context("failed to update gitbutler integration")?;
branch.head = new_head;
} else {
// move commit down
if default_target.sha == parent_oid {
@ -1602,13 +1624,30 @@ pub(crate) fn reorder_commit(
cherry_rebase_group(ctx, target_oid, &mut ids_to_rebase).context("rebase failed")?;
branch.head = new_head;
branch.updated_timestamp_ms = gitbutler_time::time::now_ms();
vb_state.set_branch(branch.clone())?;
crate::integration::update_gitbutler_integration(&vb_state, ctx)
.context("failed to update gitbutler integration")?;
}
let new_tree_commit =
cherry_rebase_group(ctx, branch.head, &mut [tree_commit]).context("rebase failed")?;
let new_tree_commit = repository
.find_commit(new_tree_commit)
.context("Failed to find new tree commit")?;
branch.tree = repository
.find_real_tree(&new_tree_commit, Default::default())?
.id();
// Use the conflicted tree commit as the head.
if new_tree_commit.is_conflicted() {
branch.head = new_tree_commit.id();
}
branch.updated_timestamp_ms = gitbutler_time::time::now_ms();
vb_state.set_branch(branch.clone())?;
crate::integration::update_gitbutler_integration(&vb_state, ctx)
.context("failed to update gitbutler integration")?;
Ok(())
}
@ -1634,7 +1673,11 @@ pub(crate) fn insert_blank_commit(
commit = commit.parent(0).context("failed to find parent")?;
}
let commit_tree = commit.tree().unwrap();
let repository = ctx.repository();
let commit_tree = repository
.find_real_tree(&commit, Default::default())
.unwrap();
let blank_commit_oid = ctx.commit("", &commit_tree, &[&commit], None)?;
if commit.id() == branch.head && offset < 0 {
@ -1677,6 +1720,10 @@ pub(crate) fn undo_commit(
.find_commit(commit_oid)
.context("failed to find commit")?;
if commit.is_conflicted() {
bail!("Can not undo a conflicted commit");
}
let new_commit_oid;
if branch.head == commit_oid {
@ -1735,6 +1782,10 @@ pub(crate) fn squash(
.parent(0)
.context("failed to find parent commit")?;
if commit_to_squash.is_conflicted() || parent_commit.is_conflicted() {
bail!("Can not squash conflicted commits");
}
let pushed_commit_oids = branch.upstream_head.map_or_else(
|| Ok(vec![]),
|upstream_head| ctx.l(upstream_head, LogUntil::Commit(default_target.sha)),
@ -1900,6 +1951,11 @@ pub(crate) fn move_commit(
.repository()
.find_commit(commit_id)
.context("failed to find commit")?;
if source_branch_head.is_conflicted() {
bail!("Can not move conflicted commits");
}
let source_branch_head_parent = source_branch_head
.parent(0)
.context("failed to get parent commit")?;

View File

@ -742,6 +742,7 @@ fn commit_id_can_be_generated_or_specified() -> Result<()> {
// The change ID should always be generated by calling CommitHeadersV2::new
Some(CommitHeadersV2 {
change_id: "my-change-id".to_string(),
conflicted: None,
}),
)
.expect("failed to commit");

View File

@ -10,6 +10,7 @@ pub trait CommitExt {
fn message_bstr(&self) -> &BStr;
fn change_id(&self) -> Option<String>;
fn is_signed(&self) -> bool;
fn is_conflicted(&self) -> bool;
}
impl<'repo> CommitExt for git2::Commit<'repo> {
@ -23,4 +24,10 @@ impl<'repo> CommitExt for git2::Commit<'repo> {
fn is_signed(&self) -> bool {
self.header_field_bytes("gpgsig").is_ok()
}
fn is_conflicted(&self) -> bool {
self.gitbutler_headers()
.and_then(|headers| headers.conflicted.map(|conflicted| conflicted > 0))
.unwrap_or(false)
}
}

View File

@ -11,6 +11,9 @@ 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 {
/// A property we can use to determine if two different commits are
/// actually the same "patch" at different points in time. We carry it
/// forwards when you rebase a commit in GitButler.
change_id: String,
}
@ -18,19 +21,25 @@ struct CommitHeadersV1 {
const V2_HEADERS_VERSION: &str = "2";
const V2_CHANGE_ID_HEADER: &str = "gitbutler-change-id";
const V2_CONFLICTED_HEADER: &str = "gitbutler-conflicted";
#[derive(Debug, Clone)]
pub struct CommitHeadersV2 {
/// A property we can use to determine if two different commits are
/// actually the same "patch" at different points in time. We carry it
/// forwards when you rebase a commit in GitButler.
pub change_id: String,
/// A property used to indicate that we've written a conflicted tree to a
/// commit. This is only written if the property is present. Conflicted
/// commits should never make it into the main trunk.
pub conflicted: Option<u64>,
}
impl Default for CommitHeadersV2 {
fn default() -> Self {
CommitHeadersV2 {
// Change ID using base16 encoding
// NOTE(ST): Ideally, this could be a computed hash based on the patch applied, similar
// to what would happen during a rebase (if that is even the intention).
// That way, they would be stable, so tests could have reproducible hashes as well.
change_id: Uuid::new_v4().to_string(),
conflicted: None,
}
}
}
@ -39,6 +48,7 @@ impl From<CommitHeadersV1> for CommitHeadersV2 {
fn from(commit_headers_v1: CommitHeadersV1) -> CommitHeadersV2 {
CommitHeadersV2 {
change_id: commit_headers_v1.change_id,
conflicted: None,
}
}
}
@ -58,7 +68,19 @@ impl HasCommitHeaders for git2::Commit<'_> {
// We can safely assume that the change id should be UTF8
let change_id = change_id.as_str()?.to_string();
Some(CommitHeadersV2 { change_id })
let conflicted = match self.header_field_bytes(V2_CONFLICTED_HEADER) {
Ok(value) => {
let value = dbg!(value.as_str())?;
value.parse::<u64>().ok()
}
Err(_) => None,
};
Some(CommitHeadersV2 {
change_id,
conflicted,
})
} else {
// Must be for a version we don't recognise
None
@ -92,5 +114,9 @@ impl CommitHeadersV2 {
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);
if let Some(conflicted) = self.conflicted {
commit_buffer.set_header(V2_CONFLICTED_HEADER, &conflicted.to_string())
}
}
}

View File

@ -6,7 +6,10 @@ use git2::build::CheckoutBuilder;
use gitbutler_branch::{signature, Branch, SignaturePurpose, VirtualBranchesHandle};
use gitbutler_branch_actions::{list_virtual_branches, update_gitbutler_integration};
use gitbutler_command_context::CommandContext;
use gitbutler_commit::{commit_ext::CommitExt, commit_headers::HasCommitHeaders};
use gitbutler_commit::{
commit_ext::CommitExt,
commit_headers::{CommitHeadersV2, HasCommitHeaders},
};
use gitbutler_operating_modes::{
read_edit_mode_metadata, write_edit_mode_metadata, EditModeMetadata, EDIT_BRANCH_REF,
INTEGRATION_BRANCH_REF,
@ -78,14 +81,53 @@ fn checkout_edit_branch(ctx: &CommandContext, commit: &git2::Commit) -> Result<(
.checkout_head(Some(CheckoutBuilder::new().force().remove_untracked(true)))
.context("Failed to checkout head")?;
// Checkout the commit as unstaged changes
let commit_tree = commit.tree().context("Failed to get commit's tree")?;
repository
.checkout_tree(
commit_tree.as_object(),
Some(CheckoutBuilder::new().force().remove_untracked(true)),
)
.context("Failed to checkout commit")?;
// Checkout the commit as unstaged changes
if commit.is_conflicted() {
let base = commit_tree
.get_name(".conflict-base-0")
.context("Failed to get base")?;
let base = repository
.find_tree(base.id())
.context("Failed to find base tree")?;
// Ours
let ours = commit_tree
.get_name(".conflict-side-0")
.context("Failed to get base")?;
let ours = repository
.find_tree(ours.id())
.context("Failed to find base tree")?;
// Theirs
let theirs = commit_tree
.get_name(".conflict-side-1")
.context("Failed to get base")?;
let theirs = repository
.find_tree(theirs.id())
.context("Failed to find base tree")?;
let mut index = repository
.merge_trees(&base, &ours, &theirs, None)
.context("Failed to merge trees")?;
repository
.checkout_index(
Some(&mut index),
Some(
CheckoutBuilder::new()
.force()
.remove_untracked(true)
.conflict_style_diff3(true),
),
)
.context("Failed to checkout conflicted commit")?;
} else {
repository
.checkout_tree(
commit_tree.as_object(),
Some(CheckoutBuilder::new().force().remove_untracked(true)),
)
.context("Failed to checkout commit")?;
};
Ok(())
}
@ -176,6 +218,12 @@ pub(crate) fn save_and_return_to_workspace(
let tree = repository
.find_tree(tree_oid)
.context("Failed to find tree")?;
let commit_headers = commit
.gitbutler_headers()
.map(|commit_headers| CommitHeadersV2 {
conflicted: None,
..commit_headers
});
let new_commit_oid = ctx
.repository()
.commit_with_signature(
@ -185,7 +233,7 @@ pub(crate) fn save_and_return_to_workspace(
&commit.message_bstr().to_str_lossy(),
&tree,
&[&commit_parent],
commit.gitbutler_headers(),
commit_headers,
)
.context("Failed to commit new commit")?;
@ -231,8 +279,8 @@ pub(crate) fn save_and_return_to_workspace(
.find_commit(rebased_stashed_integration_changes_commit)
.context("Failed to find commit of rebased stashed integration changes commit oid")?;
let tree_thing = commit_thing
.tree()
let tree_thing = repository
.find_real_tree(&commit_thing, Default::default())
.context("Failed to get tree of commit of rebased stashed integration changes")?;
repository

View File

@ -96,6 +96,12 @@ pub struct Project {
pub snapshot_lines_threshold: Option<usize>,
#[serde(default)]
pub ignore_project_semaphore: bool,
#[serde(default = "default_false")]
pub succeeding_rebases: bool,
}
fn default_false() -> bool {
false
}
impl Project {

View File

@ -27,6 +27,7 @@ pub struct UpdateRequest {
pub use_diff_context: Option<bool>,
pub snapshot_lines_threshold: Option<usize>,
pub ignore_project_semaphore: Option<bool>,
pub succeeding_rebases: Option<bool>,
}
impl Storage {
@ -123,6 +124,10 @@ impl Storage {
project.ignore_project_semaphore = ignore_project_semaphore;
}
if let Some(succeeding_rebases) = update_request.succeeding_rebases {
project.succeeding_rebases = succeeding_rebases;
}
self.inner
.write(PROJECTS_FILE, &serde_json::to_string_pretty(&projects)?)?;

View File

@ -10,11 +10,16 @@ git2.workspace = true
gix.workspace = true
anyhow = "1.0.86"
bstr.workspace = true
tokio = { workspace = true, features = [ "rt-multi-thread", "rt", "macros", "sync" ] }
tokio = { workspace = true, features = [
"rt-multi-thread",
"rt",
"macros",
"sync",
] }
gitbutler-git.workspace = true
tracing.workspace = true
tempfile = "3.10"
serde = { workspace = true, features = ["std"]}
serde = { workspace = true, features = ["std"] }
log = "^0.4"
thiserror.workspace = true
resolve-path = "0.1.0"
@ -29,12 +34,13 @@ gitbutler-id.workspace = true
gitbutler-time.workspace = true
gitbutler-commit.workspace = true
gitbutler-url.workspace = true
uuid.workspace = true
[[test]]
name="repo"
name = "repo"
path = "tests/mod.rs"
[dev-dependencies]
gitbutler-testsupport.workspace = true
gitbutler-user.workspace = true
serde_json = { version = "1.0", features = [ "std", "arbitrary_precision" ] }
serde_json = { version = "1.0", features = ["std", "arbitrary_precision"] }

View File

@ -0,0 +1,36 @@
// tree_writer.insert(".conflict-side-0", side0.id(), 0o040000)?;
// tree_writer.insert(".conflict-side-1", side1.id(), 0o040000)?;
// tree_writer.insert(".conflict-base-0", base_tree.id(), 0o040000)?;
// tree_writer.insert(".auto-resolution", resolved_tree_id, 0o040000)?;
// tree_writer.insert(".conflict-files", conflicted_files_blob, 0o100644)?;
use std::ops::Deref;
#[derive(Default)]
pub enum ConflictedTreeKey {
/// The commit we're rebasing onto "head"
Ours,
/// The commit we're rebasing "to rebase"
Theirs,
/// The parent of "to rebase"
Base,
/// An automatic resolution of conflicts
#[default]
AutoResolution,
/// A list of conflicted files
ConflictFiles,
}
impl Deref for ConflictedTreeKey {
type Target = str;
fn deref(&self) -> &Self::Target {
match self {
ConflictedTreeKey::Ours => ".conflict-side-0",
ConflictedTreeKey::Theirs => ".conflict-side-1",
ConflictedTreeKey::Base => ".conflict-base-0",
ConflictedTreeKey::AutoResolution => ".auto-resolution",
ConflictedTreeKey::ConflictFiles => ".conflict-files",
}
}
}

View File

@ -16,3 +16,5 @@ mod config;
pub use config::Config;
pub mod askpass;
mod conflicts;

View File

@ -1,10 +1,16 @@
use anyhow::{anyhow, Context, Result};
use bstr::ByteSlice;
use git2::{build::TreeUpdateBuilder, Repository};
use gitbutler_command_context::CommandContext;
use gitbutler_commit::{commit_ext::CommitExt, commit_headers::HasCommitHeaders};
use gitbutler_commit::{
commit_ext::CommitExt,
commit_headers::{CommitHeadersV2, HasCommitHeaders},
};
use gitbutler_error::error::Marker;
use tempfile::tempdir;
use uuid::Uuid;
use crate::{LogUntil, RepoActionsExt, RepositoryExt};
use crate::{conflicts::ConflictedTreeKey, LogUntil, RepoActionsExt, RepositoryExt};
/// cherry-pick based rebase, which handles empty commits
/// this function takes a commit range and generates a Vector of commit oids
@ -20,8 +26,6 @@ pub fn cherry_rebase(
// get a list of the commits to rebase
let mut ids_to_rebase = ctx.l(from_commit_oid, LogUntil::Commit(to_commit_oid))?;
dbg!(&ids_to_rebase);
if ids_to_rebase.is_empty() {
return Ok(None);
}
@ -48,54 +52,224 @@ pub fn cherry_rebase_group(
.collect::<Result<Vec<_>, _>>()
.context("failed to read commits to rebase")?;
let repository = ctx.repository();
let new_head_id = commits_to_rebase
.into_iter()
.fold(
ctx.repository()
repository
.find_commit(target_commit_oid)
.context("failed to find new commit"),
|head, to_rebase| {
let head = head?;
let mut cherrypick_index = ctx
.repository()
.cherrypick_commit(&to_rebase, &head, 0, None)
let cherrypick_index = repository
.cherry_pick_gitbutler(&head, &to_rebase)
.context("failed to cherry pick")?;
if cherrypick_index.has_conflicts() {
return Err(anyhow!("failed to rebase")).context(Marker::BranchConflict);
if !ctx.project().succeeding_rebases {
return Err(anyhow!("failed to rebase")).context(Marker::BranchConflict);
}
commit_conflicted_cherry_result(ctx, head, to_rebase, cherrypick_index)
} else {
commit_unconflicted_cherry_result(ctx, head, to_rebase, cherrypick_index)
}
let merge_tree_oid = cherrypick_index
.write_tree_to(ctx.repository())
.context("failed to write merge tree")?;
let merge_tree = ctx
.repository()
.find_tree(merge_tree_oid)
.context("failed to find merge tree")?;
let commit_headers = to_rebase.gitbutler_headers();
let commit_oid = ctx
.repository()
.commit_with_signature(
None,
&to_rebase.author(),
&to_rebase.committer(),
&to_rebase.message_bstr().to_str_lossy(),
&merge_tree,
&[&head],
commit_headers,
)
.context("failed to create commit")?;
ctx.repository()
.find_commit(commit_oid)
.context("failed to find commit")
},
)?
.id();
Ok(new_head_id)
}
fn commit_unconflicted_cherry_result<'repository>(
ctx: &'repository CommandContext,
head: git2::Commit<'repository>,
to_rebase: git2::Commit,
mut cherrypick_index: git2::Index,
) -> Result<git2::Commit<'repository>> {
let repository = ctx.repository();
let commit_headers = to_rebase.gitbutler_headers();
let is_merge_commit = to_rebase.parent_count() > 0;
let merge_tree_oid = cherrypick_index
.write_tree_to(repository)
.context("failed to write merge tree")?;
// Remove empty merge commits
if is_merge_commit && merge_tree_oid == head.tree_id() {
return Ok(head);
}
let merge_tree = repository
.find_tree(merge_tree_oid)
.context("failed to find merge tree")?;
let commit_headers = commit_headers.map(|commit_headers| CommitHeadersV2 {
conflicted: None,
..commit_headers
});
let commit_oid = repository
.commit_with_signature(
None,
&to_rebase.author(),
&to_rebase.committer(),
&to_rebase.message_bstr().to_str_lossy(),
&merge_tree,
&[&head],
commit_headers,
)
.context("failed to create commit")?;
repository
.find_commit(commit_oid)
.context("failed to find commit")
}
fn commit_conflicted_cherry_result<'repository>(
ctx: &'repository CommandContext,
head: git2::Commit,
to_rebase: git2::Commit,
mut cherrypick_index: git2::Index,
) -> Result<git2::Commit<'repository>> {
let repository = ctx.repository();
let commit_headers = to_rebase.gitbutler_headers();
// If the commit we're rebasing is conflicted, use the commits original base.
let base_tree = if to_rebase.is_conflicted() {
repository.find_real_tree(&to_rebase, ConflictedTreeKey::Ours)?
} else {
let base_commit = to_rebase.parent(0)?;
repository.find_real_tree(&base_commit, Default::default())?
};
// in case someone checks this out with vanilla Git, we should warn why it looks like this
let readme_content =
b"You have checked out a GitButler Conflicted commit. You probably didn't mean to do this.";
let readme_blob = repository.blob(readme_content)?;
// This is what can only be described as "a tad gross" but
// AFAIK there is no good way of resolving conflicts in
// an index without writing it *somewhere*
let temporary_directory = tempdir().context("Failed to create temporary directory")?;
let branch_name = Uuid::new_v4().to_string();
let worktree = repository
.worktree(
&branch_name,
&temporary_directory.path().join("repository"),
None,
)
.context("Failed to create worktree")?;
let worktree_repository =
Repository::open_from_worktree(&worktree).context("Failed to open worktree repository")?;
worktree_repository
.set_index(&mut cherrypick_index)
.context("Failed to set cherrypick index as worktree index")?;
let mut conflicted_files = Vec::new();
// get a list of conflicted files from the index
let index_conflicts = cherrypick_index.conflicts()?.flatten().collect::<Vec<_>>();
let mut theirs: Vec<git2::IndexEntry> = vec![];
for conflict in index_conflicts {
if let Some(their) = conflict.their {
let path = std::str::from_utf8(&their.path).unwrap().to_string();
conflicted_files.push(path);
let data = repository.find_blob(their.id)?;
let data = data.content();
// For some reason we need to resolve the
// conflicts using the "their" side and
// then modify the tree afterwards.
cherrypick_index
.add_frombuffer(&their, data)
.context("Failed to add resolution")?;
theirs.push(their)
}
}
let resolved_tree = cherrypick_index
.write_tree_to(repository)
.context("Failed to write cherry index")?;
let resolved_tree = repository.find_tree(resolved_tree)?;
let mut resolved_tree_updater = TreeUpdateBuilder::new();
for their in theirs {
resolved_tree_updater.upsert(their.path, their.id, git2::FileMode::Blob);
}
let resolved_tree_id = resolved_tree_updater.create_updated(repository, &resolved_tree)?;
// convert files into a string and save as a blob
let conflicted_files_string = conflicted_files.join("\n");
let conflicted_files_blob = repository.blob(conflicted_files_string.as_bytes())?;
// create a treewriter
let mut tree_writer = repository.treebuilder(None)?;
let side0 = repository.find_real_tree(&head, ConflictedTreeKey::Ours)?;
let side1 = repository.find_real_tree(&to_rebase, ConflictedTreeKey::Theirs)?;
// save the state of the conflict, so we can recreate it later
tree_writer.insert(&*ConflictedTreeKey::Ours, side0.id(), 0o040000)?;
tree_writer.insert(&*ConflictedTreeKey::Theirs, side1.id(), 0o040000)?;
tree_writer.insert(&*ConflictedTreeKey::Base, base_tree.id(), 0o040000)?;
tree_writer.insert(
&*ConflictedTreeKey::AutoResolution,
resolved_tree_id,
0o040000,
)?;
tree_writer.insert(
&*ConflictedTreeKey::ConflictFiles,
conflicted_files_blob,
0o100644,
)?;
tree_writer.insert("README.txt", readme_blob, 0o100644)?;
let tree_oid = tree_writer.write().context("failed to write tree")?;
let commit_headers = commit_headers.map(|commit_headers| {
let conflicted_file_count = conflicted_files
.len()
.try_into()
.expect("If you have more than 2^64 conflicting files, we've got bigger problems");
CommitHeadersV2 {
conflicted: Some(conflicted_file_count),
..commit_headers
}
});
// write a commit
let commit_oid = repository
.commit_with_signature(
None,
&to_rebase.author(),
&to_rebase.committer(),
&to_rebase.message_bstr().to_str_lossy(),
&repository
.find_tree(tree_oid)
.context("failed to find tree")?,
&[&head],
commit_headers,
)
.context("failed to create commit")?;
// Tidy up worktree
{
temporary_directory.close()?;
worktree.prune(None)?;
repository
.find_branch(&branch_name, git2::BranchType::Local)?
.delete()?;
}
repository
.find_commit(commit_oid)
.context("failed to find commit")
}

View File

@ -7,16 +7,30 @@ use std::{io::Write, path::Path, process::Stdio, str};
use anyhow::{anyhow, bail, Context, Result};
use bstr::BString;
use git2::{BlameOptions, Tree};
use gitbutler_commit::{commit_buffer::CommitBuffer, commit_headers::CommitHeadersV2};
use gitbutler_commit::{
commit_buffer::CommitBuffer, commit_ext::CommitExt, commit_headers::CommitHeadersV2,
};
use gitbutler_config::git::{GbConfig, GitConfig};
use gitbutler_error::error::Code;
use gitbutler_reference::{Refname, RemoteRefname};
use tracing::instrument;
use crate::conflicts::ConflictedTreeKey;
/// Extension trait for `git2::Repository`.
///
/// For now, it collects useful methods from `gitbutler-core::git::Repository`
pub trait RepositoryExt {
fn cherry_pick_gitbutler(
&self,
head: &git2::Commit,
to_rebase: &git2::Commit,
) -> Result<git2::Index, anyhow::Error>;
fn find_real_tree(
&self,
commit: &git2::Commit,
side: ConflictedTreeKey,
) -> Result<git2::Tree, anyhow::Error>;
fn remote_branches(&self) -> Result<Vec<RemoteRefname>>;
fn remotes_as_string(&self) -> Result<Vec<String>>;
/// Open a new in-memory repository and executes the provided closure using it.
@ -370,6 +384,54 @@ impl RepositoryExt for git2::Repository {
})
.collect::<Result<Vec<_>>>()
}
/// cherry-pick, but understands GitButler conflicted states
///
/// cherry_pick_gitbutler should always be used in favour of libgit2 or gitoxide
/// cherry pick functions
fn cherry_pick_gitbutler(
&self,
head: &git2::Commit,
to_rebase: &git2::Commit,
) -> Result<git2::Index, anyhow::Error> {
// we need to do a manual 3-way patch merge
// find the base, which is the parent of to_rebase
let base = if to_rebase.is_conflicted() {
self.find_real_tree(to_rebase, ConflictedTreeKey::Ours)?
} else {
let base_commit = to_rebase.parent(0)?;
self.find_real_tree(&base_commit, Default::default())?
};
// Get the original ours
let ours = self.find_real_tree(head, ConflictedTreeKey::Theirs)?;
// Get the original theirs
let thiers = self.find_real_tree(to_rebase, ConflictedTreeKey::Theirs)?;
self.merge_trees(&base, &ours, &thiers, None)
.context("failed to merge trees for cherry pick")
}
/// Find the real tree of a commit, which is the tree of the commit if it's not in a conflicted state
/// or the parent parent tree if it is in a conflicted state
///
/// Unless you want to find a particular side, you likly want to pass Default::default()
/// as the ConfclitedTreeKey which will give the automatically resolved resolution
fn find_real_tree(
&self,
commit: &git2::Commit,
side: ConflictedTreeKey,
) -> Result<git2::Tree, anyhow::Error> {
let tree = commit.tree()?;
if commit.is_conflicted() {
let conflicted_side = tree
.get_name(&side)
.context("Failed to get conflicted side of commit")?;
self.find_tree(conflicted_side.id())
.context("failed to find subtree")
} else {
self.find_tree(tree.id()).context("failed to find subtree")
}
}
}
/// Signs the buffer with the configured gpg key, returning the signature.