Merge pull request #4899 from gitbutlerapp/base-branch-improvements

This commit is contained in:
Esteban Vega 2024-10-04 19:07:14 +02:00 committed by GitHub
commit 4de7206b33
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
21 changed files with 1104 additions and 83 deletions

View File

@ -17,6 +17,10 @@ export class BaseBranch {
@Type(() => Commit)
recentCommits!: Commit[];
lastFetchedMs?: number;
conflicted!: boolean;
diverged!: boolean;
divergedAhead!: string[];
divergedBehind!: string[];
actualPushRemoteName(): string {
return this.pushRemoteName || this.remoteName;

View File

@ -67,6 +67,27 @@ export class BaseBranchService {
});
await this.fetchFromRemotes();
}
async push(withForce?: boolean) {
this.loading.set(true);
try {
await invoke<void>('push_base_branch', {
projectId: this.projectId,
withForce
});
} catch (err: any) {
if (err.code === Code.DefaultTargetNotFound) {
// Swallow this error since user should be taken to project setup page
return;
} else if (err.code === Code.ProjectsGitAuth) {
showError('Failed to authenticate', err);
} else {
showError('Failed to push', err);
}
console.error(err);
}
await this.fetchFromRemotes();
}
}
export async function getRemoteBranches(

View File

@ -4,11 +4,12 @@
interface Props {
bottomBorder?: boolean;
backgroundColor?: boolean;
lines: Snippet;
action: Snippet;
}
const { bottomBorder = true, lines, action }: Props = $props();
const { bottomBorder = true, backgroundColor = true, lines, action }: Props = $props();
let isNotInViewport = $state(false);
</script>
@ -18,6 +19,7 @@
class:not-in-viewport={!isNotInViewport}
class:sticky-z-index={!isNotInViewport}
class:bottom-border={bottomBorder}
class:background-color={backgroundColor}
use:intersectionObserver={{
callback: (entry) => {
if (entry?.isIntersecting) {
@ -46,12 +48,15 @@
position: relative;
display: flex;
background-color: var(--clr-bg-2);
overflow: hidden;
transition: border-top var(--transition-fast);
}
.background-color {
background-color: var(--clr-bg-2);
}
.action {
display: flex;
flex-direction: column;

View File

@ -142,7 +142,7 @@
modeService!.enterEditMode(commit.id, branch!.refname);
}
$: conflicted = commit instanceof DetailedCommit && commit.conflicted;
$: conflicted = commit.conflicted;
</script>
<Modal bind:this={commitMessageModal} width="small" onSubmit={submitCommitMessageModal}>

View File

@ -18,7 +18,7 @@
import { Commit, DetailedCommit, VirtualBranch } from '$lib/vbranches/types';
import Button from '@gitbutler/ui/Button.svelte';
import LineGroup from '@gitbutler/ui/commitLines/LineGroup.svelte';
import { LineManagerFactory } from '@gitbutler/ui/commitLines/lineManager';
import { LineManagerFactory, LineSpacer } from '@gitbutler/ui/commitLines/lineManager';
import type { Snippet } from 'svelte';
import { goto } from '$app/navigation';
@ -52,14 +52,6 @@
const reorderDropzoneManagerFactory = getContext(ReorderDropzoneManagerFactory);
const gitHost = getGitHost();
// TODO: Why does eslint-svelte-plugin complain about enum?
// eslint-disable-next-line svelte/valid-compile
enum LineSpacer {
Remote = 'remote-spacer',
Local = 'local-spacer',
LocalAndRemote = 'local-and-remote-spacer'
}
const mappedRemoteCommits = $derived(
remoteCommits.length > 0
? [...remoteCommits.map(transformAnyCommit), { id: LineSpacer.Remote }]

View File

@ -1,78 +1,236 @@
<script lang="ts">
import IntegrateUpstreamModal from './IntegrateUpstreamModal.svelte';
import Spacer from '../shared/Spacer.svelte';
import { Project } from '$lib/backend/projects';
import { BaseBranchService } from '$lib/baseBranch/baseBranchService';
import CommitAction from '$lib/commit/CommitAction.svelte';
import CommitCard from '$lib/commit/CommitCard.svelte';
import { transformAnyCommit } from '$lib/commitLines/transformers';
import { projectMergeUpstreamWarningDismissed } from '$lib/config/config';
import { getGitHost } from '$lib/gitHost/interface/gitHost';
import { ModeService } from '$lib/modes/service';
import { showInfo } from '$lib/notifications/toasts';
import InfoMessage from '$lib/shared/InfoMessage.svelte';
import { groupByCondition } from '$lib/utils/array';
import { getContext } from '$lib/utils/context';
import { BranchController } from '$lib/vbranches/branchController';
import Button from '@gitbutler/ui/Button.svelte';
import Checkbox from '@gitbutler/ui/Checkbox.svelte';
import Modal from '@gitbutler/ui/Modal.svelte';
import Tooltip from '@gitbutler/ui/Tooltip.svelte';
import LineGroup from '@gitbutler/ui/commitLines/LineGroup.svelte';
import { LineManagerFactory, LineSpacer } from '@gitbutler/ui/commitLines/lineManager';
import { tick } from 'svelte';
import type { BaseBranch } from '$lib/baseBranch/baseBranch';
export let base: BaseBranch;
interface Props {
base: BaseBranch;
}
const { base }: Props = $props();
const resetBaseTo = {
local: {
title: 'Push local changes',
header:
'You are about to reset the upstream target branch to the local branch. This will lose all the remote changes not in the local branch.',
content: 'You will force-push your local changes to the remote branch.',
tooltip:
'Resets the upstream branch to the local target branch. Will lose all the remote changes not in the local branch.',
color: 'pop',
handler: handlePushLocalChanges,
action: pushBaseBranch
},
remote: {
title: 'Discard local changes',
header:
'You are about to reset the target branch to the remote branch. This will lose all the changes ahead of the remote branch.',
content: 'You are about to hard reset your local base branch to the remote branch',
tooltip: `Choose how to integrate the commits from ${base.branchName} into the base of all applied virtual branches`,
color: 'warning',
handler: handleMergeUpstream,
action: updateBaseBranch
}
} as const;
type ResetBaseStrategy = keyof typeof resetBaseTo;
const baseBranchService = getContext(BaseBranchService);
const branchController = getContext(BranchController);
const modeService = getContext(ModeService);
const gitHost = getGitHost();
const project = getContext(Project);
const lineManagerFactory = getContext(LineManagerFactory);
const mode = modeService.mode;
const mergeUpstreamWarningDismissed = projectMergeUpstreamWarningDismissed(
branchController.projectId
const mode = $derived(modeService.mode);
const mergeUpstreamWarningDismissed = $derived(
projectMergeUpstreamWarningDismissed(branchController.projectId)
);
let updateTargetModal: Modal;
let integrateUpstreamModal: IntegrateUpstreamModal | undefined;
let mergeUpstreamWarningDismissedCheckbox = false;
let baseBranchIsUpdating = $state<boolean>(false);
const baseBranchConflicted = $derived(base.conflicted);
let updateTargetModal = $state<Modal>();
let resetBaseStrategy = $state<ResetBaseStrategy | undefined>(undefined);
let confirmResetModal = $state<Modal>();
const confirmResetModalOpen = $derived(!!confirmResetModal?.imports.open);
let integrateUpstreamModal = $state<IntegrateUpstreamModal>();
const integrateUpstreamModalOpen = $derived(!!integrateUpstreamModal?.imports.open);
let mergeUpstreamWarningDismissedCheckbox = $state<boolean>(false);
$: multiple = base ? base.upstreamCommits.length > 1 || base.upstreamCommits.length === 0 : false;
const pushButtonTooltip = $derived.by(() => {
if (onlyLocalAhead) return 'Push your local changes to upstream';
if (base.conflicted) return 'Cannot push while there are conflicts';
return resetBaseTo.local.tooltip;
});
const multiple = $derived(
base ? base.upstreamCommits.length > 1 || base.upstreamCommits.length === 0 : false
);
const onlyLocalAhead = $derived(
base.diverged && base.divergedBehind.length === 0 && base.divergedAhead.length > 0
);
const { satisfied: commitsAhead, rest: localAndRemoteCommits } = $derived(
groupByCondition(base.recentCommits, (c) => base.divergedAhead.includes(c.id))
);
const mappedRemoteCommits = $derived(
base.upstreamCommits.length > 0
? [...base.upstreamCommits.map(transformAnyCommit), { id: LineSpacer.Remote }]
: []
);
const mappedLocalCommits = $derived.by(() => {
if (!base.diverged) return [];
return commitsAhead.length > 0
? [...commitsAhead.map(transformAnyCommit), { id: LineSpacer.Local }]
: [];
});
const mappedLocalAndRemoteCommits = $derived.by(() => {
return localAndRemoteCommits.length > 0
? [...localAndRemoteCommits.map(transformAnyCommit), { id: LineSpacer.LocalAndRemote }]
: [];
});
const lineManager = $derived(
lineManagerFactory.build(
{
remoteCommits: mappedRemoteCommits,
localCommits: mappedLocalCommits,
localAndRemoteCommits: mappedLocalAndRemoteCommits,
integratedCommits: []
},
true
)
);
async function updateBaseBranch() {
let infoText = await branchController.updateBaseBranch();
baseBranchIsUpdating = true;
const infoText = await branchController.updateBaseBranch();
if (infoText) {
showInfo('Stashed conflicting branches', infoText);
}
await tick();
baseBranchIsUpdating = false;
}
function mergeUpstream() {
if (project.succeedingRebases) {
async function handleMergeUpstream() {
if (project.succeedingRebases && !onlyLocalAhead) {
integrateUpstreamModal?.show();
} else {
if (mergeUpstreamWarningDismissedCheckbox) {
updateBaseBranch();
} else {
updateTargetModal.show();
}
return;
}
if (base.diverged) {
await confirmResetBranch('remote');
return;
}
if ($mergeUpstreamWarningDismissed) {
updateBaseBranch();
return;
}
updateTargetModal?.show();
}
async function pushBaseBranch() {
baseBranchIsUpdating = true;
await baseBranchService.push(!onlyLocalAhead);
await tick();
await baseBranchService.refresh();
baseBranchIsUpdating = false;
}
async function confirmResetBranch(strategy: ResetBaseStrategy) {
resetBaseStrategy = strategy;
await tick();
confirmResetModal?.show();
}
async function handlePushLocalChanges() {
if (onlyLocalAhead) {
await pushBaseBranch();
return;
}
await confirmResetBranch('local');
}
</script>
<div class="wrapper">
<div class="info-text text-13">
There {multiple ? 'are' : 'is'}
{base.upstreamCommits.length} unmerged upstream
{multiple ? 'commits' : 'commit'}
{#if base.diverged}
<div class="message-wrapper">
{#if !onlyLocalAhead}
<InfoMessage style="warning" filled outlined={false}>
<svelte:fragment slot="content">
Your local target branch has diverged from upstream.
<br />
Target branch is
<b>
{`ahead by ${base.divergedAhead.length}`}
</b>
commits and
<b>
{`behind by ${base.divergedBehind.length}`}
</b>
commits
</svelte:fragment>
</InfoMessage>
{:else}
<InfoMessage style="neutral" filled outlined={false}>
<svelte:fragment slot="content">
Your local target branch is
<b>
{`ahead by ${base.divergedAhead.length}`}
</b>
commits
</svelte:fragment>
</InfoMessage>
{/if}
</div>
{/if}
{#if !base.diverged && base.upstreamCommits.length > 0}
<div class="header-wrapper">
<div class="info-text text-13">
There {multiple ? 'are' : 'is'}
{base.upstreamCommits.length} unmerged upstream
{multiple ? 'commits' : 'commit'}
</div>
{#if base.upstreamCommits?.length > 0}
<IntegrateUpstreamModal bind:this={integrateUpstreamModal} />
<Button
style="pop"
kind="solid"
tooltip={`Merges the commits from ${base.branchName} into the base of all applied virtual branches`}
disabled={$mode?.type !== 'OpenWorkspace' || integrateUpstreamModal?.imports.open}
loading={integrateUpstreamModal?.imports.open}
onclick={mergeUpstream}
disabled={$mode?.type !== 'OpenWorkspace' || integrateUpstreamModalOpen}
loading={integrateUpstreamModalOpen}
onclick={handleMergeUpstream}
>
Merge into common base
</Button>
</div>
{/if}
<div class="wrapper">
<!-- UPSTREAM COMMITS -->
{#if base.upstreamCommits?.length > 0}
<div>
{#each base.upstreamCommits as commit, index}
<CommitCard
@ -82,25 +240,114 @@
isUnapplied={true}
commitUrl={$gitHost?.commitUrl(commit.id)}
type="remote"
/>
>
{#snippet lines(topHeightPx)}
<LineGroup lineGroup={lineManager.get(commit.id)} {topHeightPx} />
{/snippet}
</CommitCard>
{/each}
</div>
<Spacer margin={2} />
{#if base.diverged}
<CommitAction backgroundColor={false}>
{#snippet lines()}
<LineGroup lineGroup={lineManager.get(LineSpacer.Remote)} topHeightPx={0} />
{/snippet}
{#snippet action()}
<Button
wide
icon="warning"
kind="solid"
style={resetBaseTo.remote.color}
tooltip={resetBaseTo.remote.tooltip}
loading={baseBranchIsUpdating || integrateUpstreamModalOpen}
disabled={$mode?.type !== 'OpenWorkspace' ||
baseBranchIsUpdating ||
integrateUpstreamModalOpen}
onclick={resetBaseTo.remote.handler}
>
Integrate upstream changes
</Button>
{/snippet}
</CommitAction>
{/if}
{/if}
<!-- DIVERGED (LOCAL) COMMITS -->
{#if commitsAhead.length > 0}
<div>
{#each commitsAhead as commit, index}
<CommitCard
{commit}
first={index === 0}
last={index === commitsAhead.length - 1}
isUnapplied={true}
commitUrl={$gitHost?.commitUrl(commit.id)}
type="local"
>
{#snippet lines(topHeightPx)}
<LineGroup lineGroup={lineManager.get(commit.id)} {topHeightPx} />
{/snippet}
</CommitCard>
{/each}
</div>
<CommitAction backgroundColor={false}>
{#snippet lines()}
<LineGroup lineGroup={lineManager.get(LineSpacer.Local)} topHeightPx={0} />
{/snippet}
{#snippet action()}
<div class="local-actions-wrapper">
<Button
wide
style={resetBaseTo.local.color}
icon={onlyLocalAhead ? undefined : 'warning'}
kind="solid"
tooltip={pushButtonTooltip}
loading={baseBranchIsUpdating || confirmResetModalOpen}
disabled={$mode?.type !== 'OpenWorkspace' ||
baseBranchIsUpdating ||
confirmResetModalOpen ||
baseBranchConflicted}
onclick={resetBaseTo.local.handler}
>
{onlyLocalAhead ? 'Push' : resetBaseTo.local.title}
</Button>
{#if onlyLocalAhead}
<Button
wide
style="error"
icon="warning"
kind="solid"
tooltip="Discard your local changes"
disabled={$mode?.type !== 'OpenWorkspace' || integrateUpstreamModalOpen}
loading={integrateUpstreamModalOpen}
onclick={handleMergeUpstream}
>
Discard local changes
</Button>
{/if}
</div>
{/snippet}
</CommitAction>
{/if}
<!-- LOCAL AND REMOTE COMMITS -->
<div>
<Tooltip text="Current base for virtual branches.">
<h1 class="text-13 info-text text-bold">Local</h1>
</Tooltip>
{#each base.recentCommits as commit, index}
{#each localAndRemoteCommits as commit, index}
<CommitCard
{commit}
first={index === 0}
last={index === base.recentCommits.length - 1}
last={index === localAndRemoteCommits.length - 1}
isUnapplied={true}
commitUrl={$gitHost?.commitUrl(commit.id)}
type="localAndRemote"
/>
>
{#snippet lines(topHeightPx)}
<LineGroup lineGroup={lineManager.get(commit.id)} {topHeightPx} />
{/snippet}
</CommitCard>
{/each}
</div>
</div>
@ -146,17 +393,71 @@
{/snippet}
</Modal>
{#if resetBaseStrategy}
<Modal
width="small"
title={resetBaseTo[resetBaseStrategy].title}
bind:this={confirmResetModal}
onSubmit={async (close) => {
if (resetBaseStrategy) await resetBaseTo[resetBaseStrategy].action();
close();
}}
>
<div class="modal-content">
<p class="text-12">
{resetBaseTo[resetBaseStrategy].content}
<br />
<br />
{#if resetBaseStrategy === 'local'}
{base.divergedBehind.length > 1
? `The ${base.divergedBehind.length} commits in the remote branch will be lost.`
: 'The commit in the remote branch will be lost.'}
{:else if resetBaseStrategy === 'remote'}
{base.divergedAhead.length > 1
? `The ${base.divergedAhead.length} commits in the local branch will be lost.`
: 'The commit in the local branch will be lost.'}
{/if}
</p>
</div>
{#snippet controls(close)}
<Button style="ghost" outline onclick={close}>Cancel</Button>
<Button style="error" kind="solid" type="submit" icon="warning"
>{resetBaseTo[resetBaseStrategy!].title}</Button
>
{/snippet}
</Modal>
{/if}
<IntegrateUpstreamModal bind:this={integrateUpstreamModal} />
<style>
.wrapper {
.header-wrapper {
display: flex;
flex-direction: column;
gap: 16px;
}
.message-wrapper {
display: flex;
flex-direction: column;
margin-bottom: 20px;
gap: 16px;
}
.wrapper {
display: flex;
flex-direction: column;
}
.info-text {
opacity: 0.5;
}
.local-actions-wrapper {
display: flex;
width: 100%;
gap: 8px;
}
.modal-content {
display: flex;
flex-direction: column;

View File

@ -7,9 +7,11 @@
import SelectItem from '$lib/select/SelectItem.svelte';
import { getContext } from '$lib/utils/context';
import {
getBaseBrancheResolution,
getResolutionApproach,
sortStatusInfo,
UpstreamIntegrationService,
type BaseBranchResolutionApproach,
type BranchStatusesWithBranches,
type BranchStatusInfo,
type Resolution
@ -39,6 +41,8 @@
let results = $state(new SvelteMap<string, Resolution>());
let statuses = $state<BranchStatusInfo[]>([]);
let expanded = $state<boolean>(false);
let baseResolutionApproach = $state<BaseBranchResolutionApproach>('hardReset');
let targetCommitOid = $state<string | undefined>(undefined);
$effect(() => {
if ($branchStatuses?.type !== 'updatesRequired') {
@ -68,10 +72,35 @@
statuses = statusesTmp;
});
// Re-fetch upstream statuses if the target commit oid changes
$effect(() => {
if (targetCommitOid) {
branchStatuses = upstreamIntegrationService.upstreamStatuses(targetCommitOid);
}
});
// Resolve the target commit oid if the base branch diverged and the the resolution
// approach is changed
$effect(() => {
if ($base?.diverged) {
upstreamIntegrationService.resolveUpstreamIntegration(baseResolutionApproach).then((Oid) => {
targetCommitOid = Oid;
});
}
});
function handleBaseResolutionSelection(resolution: BaseBranchResolutionApproach) {
baseResolutionApproach = resolution;
}
async function integrate() {
integratingUpstream = 'loading';
await tick();
await upstreamIntegrationService.integrateUpstream(Array.from(results.values()));
const baseResolution = getBaseBrancheResolution(targetCommitOid, baseResolutionApproach);
await upstreamIntegrationService.integrateUpstream(
Array.from(results.values()),
baseResolution
);
await baseBranchService.refresh();
integratingUpstream = 'completed';
@ -144,6 +173,34 @@
{/if}
</div>
{/if}
{#if $base?.diverged}
<div class="branch-status">
<div class="description">
<h5 class="text-16">{$base.branchName ?? 'Unknown'}</h5>
<p>Diverged</p>
</div>
<div class="action">
<Select
value={baseResolutionApproach}
onselect={handleBaseResolutionSelection}
options={[
{ label: 'Rebase', value: 'rebase' },
// { label: 'Merge', value: 'merge' }, hide merging for now
{ label: 'Hard reset', value: 'hardReset' }
]}
>
{#snippet itemSnippet({ item, highlighted })}
<SelectItem selected={highlighted} {highlighted}>
{item.label}
</SelectItem>
{/snippet}
</Select>
</div>
</div>
{/if}
{#if statuses.length > 0}
<div class="statuses">
{#each statuses as { branch, status }}
@ -168,10 +225,7 @@
onselect={(value) => {
const result = results.get(branch.id)!;
results.set(branch.id, {
...result,
approach: { type: value as 'rebase' | 'merge' | 'unapply' }
});
results.set(branch.id, { ...result, approach: { type: value } });
}}
options={[
{ label: 'Rebase', value: 'rebase' },

View File

@ -15,7 +15,8 @@
const displayButton = $derived.by(() => {
const hasUpstreamCommits = ($base?.upstreamCommits?.length ?? 0) > 0;
return hasUpstreamCommits;
const diverged = $base?.diverged ?? true;
return hasUpstreamCommits && !diverged;
});
let modal = $state<IntegrateUpstreamModal>();

View File

@ -17,6 +17,11 @@
const base = baseBranchService.base;
$: selected = $page.url.href.endsWith('/base');
$: baseBranchDiverged = !!$base?.diverged;
$: baseBranchAheadOnly = baseBranchDiverged && !!$base?.divergedBehind?.length === false;
$: divergenceTooltip = baseBranchAheadOnly
? 'Your local target branch is ahead of its upstream'
: 'Your local target branch has diverged from its upstream';
</script>
<DomainButton
@ -41,11 +46,21 @@
<Tooltip text="The branch your Workspace branches are based on and merge into.">
<span class="text-14 text-semibold trunk-label">Target</span>
</Tooltip>
{#if ($base?.behind || 0) > 0}
{#if ($base?.behind || 0) > 0 && !baseBranchDiverged}
<Tooltip text="Unmerged upstream commits">
<Badge label={$base?.behind || 0} />
</Tooltip>
{/if}
{#if baseBranchDiverged}
<Tooltip text={divergenceTooltip}>
<div>
<Icon
name={baseBranchAheadOnly ? 'info' : 'warning'}
color={baseBranchAheadOnly ? undefined : 'warning'}
/>
</div>
</Tooltip>
{/if}
<SyncButton />
</div>
<div class="base-branch-label">

View File

@ -1,6 +1,8 @@
type Predicate<T> = (item: T) => boolean;
type ItemsSatisfyResult = 'all' | 'some' | 'none';
export function itemsSatisfy<T>(arr: T[], predicate: (item: T) => boolean): ItemsSatisfyResult {
export function itemsSatisfy<T>(arr: T[], predicate: Predicate<T>): ItemsSatisfyResult {
let satisfyCount = 0;
let offenseCount = 0;
for (const item of arr) {
@ -29,6 +31,27 @@ export function chunk<T>(arr: T[], size: number) {
);
}
interface GroupByResult<T> {
satisfied: T[];
rest: T[];
}
export function groupByCondition<T>(arr: T[], predicate: Predicate<T>): GroupByResult<T> {
const satisfied: T[] = [];
const rest: T[] = [];
for (const item of arr) {
if (predicate(item)) {
satisfied.push(item);
continue;
}
rest.push(item);
}
return { satisfied, rest };
}
export function unique<T>(arr: T[]): T[] {
return Array.from(new Set(arr));
}

View File

@ -228,6 +228,7 @@ export class Commit {
changeId!: string;
isSigned!: boolean;
parentIds!: string[];
conflicted!: boolean;
prev?: Commit;
next?: Commit;

View File

@ -37,6 +37,25 @@ export type Resolution = {
approach: ResolutionApproach;
};
export type BaseBranchResolutionApproach = 'rebase' | 'merge' | 'hardReset';
export type BaseBranchResolution = {
targetCommitOid: string;
approach: { type: BaseBranchResolutionApproach };
};
export function getBaseBrancheResolution(
targetCommitOid: string | undefined,
approach: BaseBranchResolutionApproach
): BaseBranchResolution | undefined {
if (!targetCommitOid) return;
return {
targetCommitOid,
approach: { type: approach }
};
}
export function getResolutionApproach(statusInfo: BranchStatusInfo): ResolutionApproach {
if (statusInfo.status.type === 'fullyIntegrated') {
return { type: 'delete' };
@ -79,10 +98,11 @@ export class UpstreamIntegrationService {
private virtualBranchService: VirtualBranchService
) {}
upstreamStatuses(): Readable<BranchStatusesWithBranches | undefined> {
upstreamStatuses(targetCommitOid?: string): Readable<BranchStatusesWithBranches | undefined> {
const branchStatuses = readable<BranchStatusesResponse | undefined>(undefined, (set) => {
invoke<BranchStatusesResponse>('upstream_integration_statuses', {
projectId: this.project.id
projectId: this.project.id,
targetCommitOid
}).then(set);
});
@ -113,7 +133,18 @@ export class UpstreamIntegrationService {
return branchStatusesWithBranches;
}
async integrateUpstream(resolutions: Resolution[]) {
return await invoke('integrate_upstream', { projectId: this.project.id, resolutions });
async integrateUpstream(resolutions: Resolution[], baseBranchResolution?: BaseBranchResolution) {
return await invoke('integrate_upstream', {
projectId: this.project.id,
resolutions,
baseBranchResolution
});
}
async resolveUpstreamIntegration(type: BaseBranchResolutionApproach) {
return await invoke<string>('resolve_upstream_integration', {
projectId: this.project.id,
resolutionApproach: { type }
});
}
}

View File

@ -1,5 +1,10 @@
use super::r#virtual as vbranch;
use crate::upstream_integration::{self, BranchStatuses, Resolution, UpstreamIntegrationContext};
use crate::move_commits;
use crate::reorder_commits;
use crate::upstream_integration::{
self, BaseBranchResolution, BaseBranchResolutionApproach, BranchStatuses, Resolution,
UpstreamIntegrationContext,
};
use crate::{
base,
base::BaseBranch,
@ -9,7 +14,6 @@ use crate::{
remote::{RemoteBranch, RemoteBranchData, RemoteCommit},
VirtualBranchesExt,
};
use crate::{move_commits, reorder_commits};
use anyhow::{Context, Result};
use gitbutler_branch::{BranchCreateRequest, BranchId, BranchOwnershipClaims, BranchUpdateRequest};
use gitbutler_command_context::CommandContext;
@ -158,6 +162,11 @@ pub fn set_target_push_remote(project: &Project, push_remote: &str) -> Result<()
base::set_target_push_remote(&ctx, push_remote)
}
pub fn push_base_branch(project: &Project, with_force: bool) -> Result<()> {
let ctx = CommandContext::open(project)?;
base::push(&ctx, with_force)
}
pub fn integrate_upstream_commits(project: &Project, branch_id: BranchId) -> Result<()> {
let ctx = open_with_verify(project)?;
assure_open_workspace_mode(&ctx)
@ -514,16 +523,27 @@ pub fn get_uncommited_files_reusable(project: &Project) -> Result<DiffByPathMap>
crate::branch::get_uncommited_files_raw(&context, guard.read_permission())
}
pub fn upstream_integration_statuses(project: &Project) -> Result<BranchStatuses> {
pub fn upstream_integration_statuses(
project: &Project,
target_commit_oid: Option<git2::Oid>,
) -> Result<BranchStatuses> {
let command_context = CommandContext::open(project)?;
let mut guard = project.exclusive_worktree_access();
let context = UpstreamIntegrationContext::open(&command_context, guard.write_permission())?;
let context = UpstreamIntegrationContext::open(
&command_context,
target_commit_oid,
guard.write_permission(),
)?;
upstream_integration::upstream_integration_statuses(&context)
}
pub fn integrate_upstream(project: &Project, resolutions: &[Resolution]) -> Result<()> {
pub fn integrate_upstream(
project: &Project,
resolutions: &[Resolution],
base_branch_resolution: Option<BaseBranchResolution>,
) -> Result<()> {
let command_context = CommandContext::open(project)?;
let mut guard = project.exclusive_worktree_access();
@ -535,6 +555,21 @@ pub fn integrate_upstream(project: &Project, resolutions: &[Resolution]) -> Resu
upstream_integration::integrate_upstream(
&command_context,
resolutions,
base_branch_resolution,
guard.write_permission(),
)
}
pub fn resolve_upstream_integration(
project: &Project,
resolution_approach: BaseBranchResolutionApproach,
) -> Result<git2::Oid> {
let command_context = CommandContext::open(project)?;
let mut guard = project.exclusive_worktree_access();
upstream_integration::resolve_upstream_integration(
&command_context,
resolution_approach,
guard.write_permission(),
)
}

View File

@ -39,6 +39,12 @@ pub struct BaseBranch {
pub upstream_commits: Vec<RemoteCommit>,
pub recent_commits: Vec<RemoteCommit>,
pub last_fetched_ms: Option<u128>,
pub conflicted: bool,
pub diverged: bool,
#[serde(with = "gitbutler_serde::oid_vec")]
pub diverged_ahead: Vec<git2::Oid>,
#[serde(with = "gitbutler_serde::oid_vec")]
pub diverged_behind: Vec<git2::Oid>,
}
pub(crate) fn get_base_branch_data(ctx: &CommandContext) -> Result<BaseBranch> {
@ -558,6 +564,26 @@ pub(crate) fn target_to_base_branch(ctx: &CommandContext, target: &Target) -> Re
let commit = branch.get().peel_to_commit()?;
let oid = commit.id();
// determine if the base branch is behind it's upstream
let (number_commits_ahead, number_commits_behind) = repo.graph_ahead_behind(target.sha, oid)?;
let diverged_ahead = repo
.log(target.sha, LogUntil::Take(number_commits_ahead))
.context("failed to get fork point")?
.iter()
.map(|commit| commit.id())
.collect::<Vec<_>>();
let diverged_behind = repo
.log(oid, LogUntil::Take(number_commits_behind))
.context("failed to get fork point")?
.iter()
.map(|commit| commit.id())
.collect::<Vec<_>>();
// if there are commits ahead of the base branch consider it diverged
let diverged = !diverged_ahead.is_empty();
// gather a list of commits between oid and target.sha
let upstream_commits = repo
.log(oid, LogUntil::Commit(target.sha))
@ -574,6 +600,9 @@ pub(crate) fn target_to_base_branch(ctx: &CommandContext, target: &Target) -> Re
.map(commit_to_remote_commit)
.collect::<Vec<_>>();
// we assume that only local commits can be conflicted
let conflicted = recent_commits.iter().any(|commit| commit.conflicted);
// there has got to be a better way to do this.
let push_remote_url = match target.push_remote_name {
Some(ref name) => match repo.find_remote(name) {
@ -604,6 +633,10 @@ pub(crate) fn target_to_base_branch(ctx: &CommandContext, target: &Target) -> Re
.map(FetchResult::timestamp)
.copied()
.map(|t| t.duration_since(time::UNIX_EPOCH).unwrap().as_millis()),
conflicted,
diverged,
diverged_ahead,
diverged_behind,
};
Ok(base)
}
@ -611,3 +644,10 @@ pub(crate) fn target_to_base_branch(ctx: &CommandContext, target: &Target) -> Re
fn default_target(base_path: &Path) -> Result<Target> {
VirtualBranchesHandle::new(base_path).get_default_target()
}
pub(crate) fn push(ctx: &CommandContext, with_force: bool) -> Result<()> {
ctx.assure_resolved()?;
let target = default_target(&ctx.project().gb_dir())?;
let _ = ctx.push(target.sha, &target.branch, with_force, None, None);
Ok(())
}

View File

@ -8,11 +8,11 @@ pub use actions::{
get_uncommited_files_reusable, insert_blank_commit, integrate_upstream,
integrate_upstream_commits, list_local_branches, list_remote_commit_files,
list_virtual_branches, list_virtual_branches_cached, move_commit, move_commit_file,
push_virtual_branch, reorder_commit, reset_files, reset_virtual_branch,
save_and_unapply_virutal_branch, set_base_branch, set_target_push_remote, squash,
unapply_ownership, unapply_without_saving_virtual_branch, undo_commit, update_base_branch,
update_branch_order, update_commit_message, update_virtual_branch,
upstream_integration_statuses,
push_base_branch, push_virtual_branch, reorder_commit, reset_files, reset_virtual_branch,
resolve_upstream_integration, save_and_unapply_virutal_branch, set_base_branch,
set_target_push_remote, squash, unapply_ownership, unapply_without_saving_virtual_branch,
undo_commit, update_base_branch, update_branch_order, update_commit_message,
update_virtual_branch, upstream_integration_statuses,
};
mod r#virtual;

View File

@ -55,6 +55,7 @@ pub struct RemoteCommit {
pub change_id: Option<String>,
#[serde(with = "gitbutler_serde::oid_vec")]
pub parent_ids: Vec<git2::Oid>,
pub conflicted: bool,
}
/// Return information on all local branches, while skipping gitbutler-specific branches in `refs/heads`.
@ -206,6 +207,7 @@ pub(crate) fn commit_to_remote_commit(commit: &git2::Commit) -> RemoteCommit {
author: commit.author().into(),
change_id: commit.change_id(),
parent_ids,
conflicted: commit.is_conflicted(),
}
}

View File

@ -32,6 +32,14 @@ pub enum BranchStatuses {
UpdatesRequired(Vec<(BranchId, BranchStatus)>),
}
#[derive(Serialize, Deserialize, PartialEq, Debug)]
#[serde(tag = "type", content = "subject", rename_all = "camelCase")]
pub enum BaseBranchResolutionApproach {
Rebase,
Merge,
HardReset,
}
#[derive(Serialize, Deserialize, PartialEq, Debug)]
#[serde(tag = "type", content = "subject", rename_all = "camelCase")]
enum ResolutionApproach {
@ -41,6 +49,14 @@ enum ResolutionApproach {
Delete,
}
#[derive(Serialize, Deserialize, PartialEq, Debug)]
#[serde(rename_all = "camelCase")]
pub struct BaseBranchResolution {
#[serde(with = "gitbutler_serde::oid")]
target_commit_oid: git2::Oid,
approach: BaseBranchResolutionApproach,
}
impl BranchStatus {
fn resolution_acceptable(&self, approach: &ResolutionApproach) -> bool {
match self {
@ -83,6 +99,7 @@ pub struct UpstreamIntegrationContext<'a> {
impl<'a> UpstreamIntegrationContext<'a> {
pub(crate) fn open(
command_context: &'a CommandContext,
target_commit_oid: Option<git2::Oid>,
permission: &'a mut WorktreeWritePermission,
) -> Result<Self> {
let virtual_branches_handle = command_context.project().virtual_branches();
@ -91,7 +108,12 @@ impl<'a> UpstreamIntegrationContext<'a> {
let target_branch = repository
.find_branch_by_refname(&target.branch.clone().into())?
.ok_or(anyhow!("Branch not found"))?;
let new_target = target_branch.get().peel_to_commit()?;
let new_target = target_commit_oid.map_or_else(
|| target_branch.get().peel_to_commit(),
|oid| repository.find_commit(oid),
)?;
let old_target = repository.find_commit(target.sha)?;
let virtual_branches_in_workspace = virtual_branches_handle.list_branches_in_workspace()?;
@ -203,9 +225,14 @@ pub fn upstream_integration_statuses(
pub(crate) fn integrate_upstream(
command_context: &CommandContext,
resolutions: &[Resolution],
base_branch_resolution: Option<BaseBranchResolution>,
permission: &mut WorktreeWritePermission,
) -> Result<()> {
let context = UpstreamIntegrationContext::open(command_context, permission)?;
let (target_commit_oid, base_branch_resolution_approach) = base_branch_resolution
.map(|r| (Some(r.target_commit_oid), Some(r.approach)))
.unwrap_or((None, None));
let context = UpstreamIntegrationContext::open(command_context, target_commit_oid, permission)?;
let virtual_branches_state = VirtualBranchesHandle::new(command_context.project().gb_dir());
let default_target = virtual_branches_state.get_default_target()?;
@ -250,7 +277,8 @@ pub(crate) fn integrate_upstream(
}
}
let integration_results = compute_resolutions(&context, resolutions)?;
let integration_results =
compute_resolutions(&context, resolutions, base_branch_resolution_approach)?;
{
// We preform the updates in stages. If deleting or unapplying fails, we
@ -313,13 +341,48 @@ pub(crate) fn integrate_upstream(
Ok(())
}
pub(crate) fn resolve_upstream_integration(
command_context: &CommandContext,
resolution_approach: BaseBranchResolutionApproach,
permission: &mut WorktreeWritePermission,
) -> Result<git2::Oid> {
let context = UpstreamIntegrationContext::open(command_context, None, permission)?;
let repo = command_context.repository();
let new_target_id = context.new_target.id();
let old_target_id = context.old_target.id();
let fork_point = repo.merge_base(old_target_id, new_target_id)?;
match resolution_approach {
BaseBranchResolutionApproach::HardReset => Ok(new_target_id),
BaseBranchResolutionApproach::Merge => {
let new_head = gitbutler_merge_commits(
repo,
context.old_target,
context.new_target,
&context.target_branch_name,
&context.target_branch_name,
)?;
Ok(new_head.id())
}
BaseBranchResolutionApproach::Rebase => {
let commits = repo.l(old_target_id, LogUntil::Commit(fork_point))?;
let new_head = cherry_rebase_group(repo, new_target_id, &commits, true)?;
Ok(new_head)
}
}
}
fn compute_resolutions(
context: &UpstreamIntegrationContext,
resolutions: &[Resolution],
base_branch_resolution_approach: Option<BaseBranchResolutionApproach>,
) -> Result<Vec<(BranchId, IntegrationResult)>> {
let UpstreamIntegrationContext {
repository,
new_target,
old_target,
virtual_branches_in_workspace,
target_branch_name,
..
@ -402,9 +465,17 @@ fn compute_resolutions(
// Rebase the commits, then try rebasing the tree. If
// the tree ends up conflicted, commit the tree.
// If the base branch needs to resolve its divergence
// pick only the commits that are ahead of the old target head
let lower_bound = if base_branch_resolution_approach.is_some() {
old_target.id()
} else {
new_target.id()
};
// Rebase virtual branches' commits
let virtual_branch_commits =
repository.l(virtual_branch.head, LogUntil::Commit(new_target.id()))?;
repository.l(virtual_branch.head, LogUntil::Commit(lower_bound))?;
let new_head = cherry_rebase_group(
repository,
@ -595,6 +666,7 @@ mod test {
branch_tree: branch.tree,
approach: ResolutionApproach::Rebase,
}],
None,
)
.unwrap();
@ -614,6 +686,383 @@ mod test {
assert_eq!(head_tree.id(), tree)
}
#[test]
fn test_conflicted_head_branch_resolve_divergence_hard_reset() {
let test_repository = TestingRepository::open();
let initial_commit = test_repository.commit_tree(None, &[("foo.txt", "bar")]);
let old_target = test_repository.commit_tree(Some(&initial_commit), &[("foo.txt", "baz")]);
let branch_head = test_repository.commit_tree(Some(&old_target), &[("foo.txt", "fux")]);
// new target diverged from old target
let new_target = test_repository.commit_tree(Some(&initial_commit), &[("foo.txt", "qux")]);
let branch = make_branch(branch_head.id(), branch_head.tree_id());
let context = UpstreamIntegrationContext {
_permission: None,
old_target,
new_target: new_target.clone(),
repository: &test_repository.repository,
virtual_branches_in_workspace: vec![branch.clone()],
target_branch_name: "main".to_string(),
};
assert_eq!(
upstream_integration_statuses(&context).unwrap(),
BranchStatuses::UpdatesRequired(vec![(
branch.id,
BranchStatus::Conflicted {
potentially_conflicted_uncommited_changes: false
}
)]),
);
let updates = compute_resolutions(
&context,
&[Resolution {
branch_id: branch.id,
branch_tree: branch.tree,
approach: ResolutionApproach::Rebase,
}],
Some(BaseBranchResolutionApproach::HardReset),
)
.unwrap();
assert_eq!(updates.len(), 1);
let IntegrationResult::UpdatedObjects { head, tree } = updates[0].1 else {
panic!("Should be variant UpdatedObjects")
};
let head_commit = test_repository.repository.find_commit(head).unwrap();
assert_eq!(head_commit.parent(0).unwrap().id(), new_target.id());
assert!(head_commit.is_conflicted());
let head_tree = test_repository
.repository
.find_real_tree(&head_commit, Default::default())
.unwrap();
assert_eq!(head_tree.id(), tree)
}
#[test]
fn test_unconflicted_head_branch_resolve_divergence_hard_reset() {
let test_repository = TestingRepository::open();
let initial_commit = test_repository.commit_tree(None, &[("foo.txt", "bar")]);
let old_target = test_repository.commit_tree(Some(&initial_commit), &[("foo.txt", "baz")]);
let branch_head =
test_repository.commit_tree(Some(&old_target), &[("bar.txt", "no problem")]);
// new target diverged from old target
let new_target =
test_repository.commit_tree(Some(&initial_commit), &[("other.txt", "qux")]);
let branch = make_branch(branch_head.id(), branch_head.tree_id());
let context = UpstreamIntegrationContext {
_permission: None,
old_target,
new_target: new_target.clone(),
repository: &test_repository.repository,
virtual_branches_in_workspace: vec![branch.clone()],
target_branch_name: "main".to_string(),
};
assert_eq!(
upstream_integration_statuses(&context).unwrap(),
BranchStatuses::UpdatesRequired(vec![(branch.id, BranchStatus::SaflyUpdatable)]),
);
let updates = compute_resolutions(
&context,
&[Resolution {
branch_id: branch.id,
branch_tree: branch.tree,
approach: ResolutionApproach::Rebase,
}],
Some(BaseBranchResolutionApproach::HardReset),
)
.unwrap();
assert_eq!(updates.len(), 1);
let IntegrationResult::UpdatedObjects { head, tree } = updates[0].1 else {
panic!("Should be variant UpdatedObjects")
};
let head_commit = test_repository.repository.find_commit(head).unwrap();
assert_eq!(head_commit.parent(0).unwrap().id(), new_target.id());
assert!(!head_commit.is_conflicted());
let head_tree = test_repository
.repository
.find_real_tree(&head_commit, Default::default())
.unwrap();
assert_eq!(head_tree.id(), tree)
}
#[test]
fn test_conflicted_head_branch_resolve_divergence_rebase() {
let test_repository = TestingRepository::open();
let initial_commit = test_repository.commit_tree(None, &[("foo.txt", "bar")]);
let old_target = test_repository.commit_tree(Some(&initial_commit), &[("foo.txt", "baz")]);
let branch_head = test_repository.commit_tree(Some(&old_target), &[("foo.txt", "fux")]);
// new target diverged from old target
let new_target = test_repository.commit_tree(Some(&initial_commit), &[("foo.txt", "qux")]);
let branch = make_branch(branch_head.id(), branch_head.tree_id());
let commits_to_rebase = test_repository
.repository
.l(old_target.id(), LogUntil::Commit(initial_commit.id()))
.unwrap();
let head_after_rebase = cherry_rebase_group(
&test_repository.repository,
new_target.id(),
&commits_to_rebase,
true,
)
.unwrap();
let context = UpstreamIntegrationContext {
_permission: None,
old_target,
new_target: test_repository
.repository
.find_commit(head_after_rebase)
.unwrap(),
repository: &test_repository.repository,
virtual_branches_in_workspace: vec![branch.clone()],
target_branch_name: "main".to_string(),
};
assert_eq!(
upstream_integration_statuses(&context).unwrap(),
BranchStatuses::UpdatesRequired(vec![(
branch.id,
BranchStatus::Conflicted {
potentially_conflicted_uncommited_changes: false
}
)]),
);
let updates = compute_resolutions(
&context,
&[Resolution {
branch_id: branch.id,
branch_tree: branch.tree,
approach: ResolutionApproach::Rebase,
}],
Some(BaseBranchResolutionApproach::Rebase),
)
.unwrap();
assert_eq!(updates.len(), 1);
let IntegrationResult::UpdatedObjects { head, tree } = updates[0].1 else {
panic!("Should be variant UpdatedObjects")
};
let head_commit = test_repository.repository.find_commit(head).unwrap();
assert_eq!(head_commit.parent(0).unwrap().id(), head_after_rebase);
assert!(head_commit.is_conflicted());
let head_tree = test_repository
.repository
.find_real_tree(&head_commit, Default::default())
.unwrap();
assert_eq!(head_tree.id(), tree)
}
#[test]
fn test_unconflicted_head_branch_resolve_divergence_rebase() {
let test_repository = TestingRepository::open();
let initial_commit = test_repository.commit_tree(None, &[("foo.txt", "bar")]);
let old_target = test_repository.commit_tree(Some(&initial_commit), &[("bar.txt", "baz")]);
let branch_head = test_repository.commit_tree(Some(&old_target), &[("bar.txt", "fux")]);
// new target diverged from old target
let new_target = test_repository.commit_tree(Some(&initial_commit), &[("foo.txt", "qux")]);
let branch = make_branch(branch_head.id(), branch_head.tree_id());
let commits_to_rebase = test_repository
.repository
.l(old_target.id(), LogUntil::Commit(initial_commit.id()))
.unwrap();
let head_after_rebase = cherry_rebase_group(
&test_repository.repository,
new_target.id(),
&commits_to_rebase,
true,
)
.unwrap();
let context = UpstreamIntegrationContext {
_permission: None,
old_target,
new_target: test_repository
.repository
.find_commit(head_after_rebase)
.unwrap(),
repository: &test_repository.repository,
virtual_branches_in_workspace: vec![branch.clone()],
target_branch_name: "main".to_string(),
};
assert_eq!(
upstream_integration_statuses(&context).unwrap(),
BranchStatuses::UpdatesRequired(vec![(branch.id, BranchStatus::SaflyUpdatable)]),
);
let updates = compute_resolutions(
&context,
&[Resolution {
branch_id: branch.id,
branch_tree: branch.tree,
approach: ResolutionApproach::Rebase,
}],
Some(BaseBranchResolutionApproach::Rebase),
)
.unwrap();
assert_eq!(updates.len(), 1);
let IntegrationResult::UpdatedObjects { head, tree } = updates[0].1 else {
panic!("Should be variant UpdatedObjects")
};
let head_commit = test_repository.repository.find_commit(head).unwrap();
assert_eq!(head_commit.parent(0).unwrap().id(), head_after_rebase);
assert!(!head_commit.is_conflicted());
let head_tree = test_repository
.repository
.find_real_tree(&head_commit, Default::default())
.unwrap();
assert_eq!(head_tree.id(), tree)
}
#[test]
fn test_conflicted_head_branch_resolve_divergence_merge() {
let test_repository = TestingRepository::open();
let initial_commit = test_repository.commit_tree(None, &[("foo.txt", "bar")]);
let old_target = test_repository.commit_tree(Some(&initial_commit), &[("foo.txt", "baz")]);
let branch_head = test_repository.commit_tree(Some(&old_target), &[("foo.txt", "fux")]);
// new target diverged from old target
let new_target = test_repository.commit_tree(Some(&initial_commit), &[("foo.txt", "qux")]);
let branch = make_branch(branch_head.id(), branch_head.tree_id());
let merge_commit = gitbutler_merge_commits(
&test_repository.repository,
old_target.clone(),
new_target,
"main",
"main",
)
.unwrap();
let context = UpstreamIntegrationContext {
_permission: None,
old_target,
new_target: merge_commit.clone(),
repository: &test_repository.repository,
virtual_branches_in_workspace: vec![branch.clone()],
target_branch_name: "main".to_string(),
};
assert_eq!(
upstream_integration_statuses(&context).unwrap(),
BranchStatuses::UpdatesRequired(vec![(
branch.id,
BranchStatus::Conflicted {
potentially_conflicted_uncommited_changes: false
}
)]),
);
let updates = compute_resolutions(
&context,
&[Resolution {
branch_id: branch.id,
branch_tree: branch.tree,
approach: ResolutionApproach::Rebase,
}],
Some(BaseBranchResolutionApproach::Merge),
)
.unwrap();
assert_eq!(updates.len(), 1);
let IntegrationResult::UpdatedObjects { head, tree } = updates[0].1 else {
panic!("Should be variant UpdatedObjects")
};
let head_commit = test_repository.repository.find_commit(head).unwrap();
assert_eq!(head_commit.parent(0).unwrap().id(), merge_commit.id());
assert!(head_commit.is_conflicted());
let head_tree = test_repository
.repository
.find_real_tree(&head_commit, Default::default())
.unwrap();
assert_eq!(head_tree.id(), tree)
}
#[test]
fn test_unconflicted_head_branch_resolve_divergence_merge() {
let test_repository = TestingRepository::open();
let initial_commit = test_repository.commit_tree(None, &[("foo.txt", "bar")]);
let old_target = test_repository.commit_tree(Some(&initial_commit), &[("bar.txt", "baz")]);
let branch_head = test_repository.commit_tree(Some(&old_target), &[("bar.txt", "fux")]);
// new target diverged from old target
let new_target = test_repository.commit_tree(Some(&initial_commit), &[("foo.txt", "qux")]);
let branch = make_branch(branch_head.id(), branch_head.tree_id());
let merge_commit = gitbutler_merge_commits(
&test_repository.repository,
old_target.clone(),
new_target,
"main",
"main",
)
.unwrap();
let context = UpstreamIntegrationContext {
_permission: None,
old_target,
new_target: merge_commit.clone(),
repository: &test_repository.repository,
virtual_branches_in_workspace: vec![branch.clone()],
target_branch_name: "main".to_string(),
};
assert_eq!(
upstream_integration_statuses(&context).unwrap(),
BranchStatuses::UpdatesRequired(vec![(branch.id, BranchStatus::SaflyUpdatable)]),
);
let updates = compute_resolutions(
&context,
&[Resolution {
branch_id: branch.id,
branch_tree: branch.tree,
approach: ResolutionApproach::Rebase,
}],
Some(BaseBranchResolutionApproach::Merge),
)
.unwrap();
assert_eq!(updates.len(), 1);
let IntegrationResult::UpdatedObjects { head, tree } = updates[0].1 else {
panic!("Should be variant UpdatedObjects")
};
let head_commit = test_repository.repository.find_commit(head).unwrap();
assert_eq!(head_commit.parent(0).unwrap().id(), merge_commit.id());
assert!(!head_commit.is_conflicted());
let head_tree = test_repository
.repository
.find_real_tree(&head_commit, Default::default())
.unwrap();
assert_eq!(head_tree.id(), tree)
}
#[test]
fn test_conflicted_tree_branch() {
let test_repository = TestingRepository::open();

View File

@ -436,7 +436,7 @@ mod conflict_cases {
)
.unwrap();
gitbutler_branch_actions::integrate_upstream(&project, &[]).unwrap();
gitbutler_branch_actions::integrate_upstream(&project, &[], None).unwrap();
// Apply B
@ -535,7 +535,7 @@ mod conflict_cases {
)
.unwrap();
gitbutler_branch_actions::integrate_upstream(&project, &[]).unwrap();
gitbutler_branch_actions::integrate_upstream(&project, &[], None).unwrap();
// Apply B

View File

@ -159,6 +159,7 @@ fn main() {
virtual_branches::commands::get_base_branch_data,
virtual_branches::commands::set_base_branch,
virtual_branches::commands::update_base_branch,
virtual_branches::commands::push_base_branch,
virtual_branches::commands::integrate_upstream_commits,
virtual_branches::commands::update_virtual_branch,
virtual_branches::commands::update_branch_order,
@ -187,6 +188,7 @@ fn main() {
virtual_branches::commands::normalize_branch_name,
virtual_branches::commands::upstream_integration_statuses,
virtual_branches::commands::integrate_upstream,
virtual_branches::commands::resolve_upstream_integration,
virtual_branches::commands::find_commit,
stack::create_series,
stack::remove_series,

View File

@ -4,7 +4,9 @@ pub mod commands {
BranchCreateRequest, BranchId, BranchOwnershipClaims, BranchUpdateRequest,
};
use gitbutler_branch_actions::internal::PushResult;
use gitbutler_branch_actions::upstream_integration::{BranchStatuses, Resolution};
use gitbutler_branch_actions::upstream_integration::{
BaseBranchResolution, BaseBranchResolutionApproach, BranchStatuses, Resolution,
};
use gitbutler_branch_actions::{
BaseBranch, BranchListing, BranchListingDetails, BranchListingFilter, RemoteBranch,
RemoteBranchData, RemoteBranchFile, RemoteCommit, VirtualBranches,
@ -174,6 +176,20 @@ pub mod commands {
Ok(unapplied_branches)
}
#[tauri::command(async)]
#[instrument(skip(projects, windows), err(Debug))]
pub fn push_base_branch(
windows: State<'_, WindowState>,
projects: State<'_, projects::Controller>,
project_id: ProjectId,
with_force: bool,
) -> Result<(), Error> {
let project = projects.get(project_id)?;
gitbutler_branch_actions::push_base_branch(&project, with_force)?;
emit_vbranches(&windows, project_id);
Ok(())
}
#[tauri::command(async)]
#[instrument(skip(projects, windows), err(Debug))]
pub fn update_virtual_branch(
@ -559,10 +575,14 @@ pub mod commands {
pub fn upstream_integration_statuses(
projects: State<'_, projects::Controller>,
project_id: ProjectId,
target_commit_oid: Option<String>,
) -> Result<BranchStatuses, Error> {
let project = projects.get(project_id)?;
let commit_oid = target_commit_oid
.map(|commit_id| git2::Oid::from_str(&commit_id).map_err(|e| anyhow!(e)))
.transpose()?;
Ok(gitbutler_branch_actions::upstream_integration_statuses(
&project,
&project, commit_oid,
)?)
}
@ -573,16 +593,35 @@ pub mod commands {
projects: State<'_, projects::Controller>,
project_id: ProjectId,
resolutions: Vec<Resolution>,
base_branch_resolution: Option<BaseBranchResolution>,
) -> Result<(), Error> {
let project = projects.get(project_id)?;
gitbutler_branch_actions::integrate_upstream(&project, &resolutions)?;
gitbutler_branch_actions::integrate_upstream(
&project,
&resolutions,
base_branch_resolution,
)?;
emit_vbranches(&windows, project_id);
Ok(())
}
#[tauri::command(async)]
#[instrument(skip(projects), err(Debug))]
pub fn resolve_upstream_integration(
projects: State<'_, projects::Controller>,
project_id: ProjectId,
resolution_approach: BaseBranchResolutionApproach,
) -> Result<String, Error> {
let project = projects.get(project_id)?;
let new_target_id =
gitbutler_branch_actions::resolve_upstream_integration(&project, resolution_approach)?;
let commit_id = git2::Oid::to_string(&new_target_id);
Ok(commit_id)
}
pub(crate) fn emit_vbranches(windows: &WindowState, project_id: projects::ProjectId) {
if let Err(error) = windows.post(gitbutler_watcher::Action::CalculateVirtualBranches(
project_id,

View File

@ -1,5 +1,11 @@
import type { CommitData, LineGroupData, LineData, Color } from '$lib/commitLines/types';
export enum LineSpacer {
Remote = 'remote-spacer',
Local = 'local-spacer',
LocalAndRemote = 'local-and-remote-spacer'
}
interface Commits {
remoteCommits: CommitData[];
localCommits: CommitData[];