Merge pull request #4760 from gitbutlerapp/Show-files-in-edit-mode

Show files in edit mode
This commit is contained in:
Caleb Owens 2024-08-27 13:51:48 +02:00 committed by GitHub
commit a7399ab083
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
34 changed files with 654 additions and 207 deletions

15
Cargo.lock generated
View File

@ -2140,6 +2140,7 @@ dependencies = [
"git2",
"git2-hooks",
"gitbutler-branch",
"gitbutler-cherry-pick",
"gitbutler-command-context",
"gitbutler-commit",
"gitbutler-diff",
@ -2175,6 +2176,15 @@ dependencies = [
"urlencoding",
]
[[package]]
name = "gitbutler-cherry-pick"
version = "0.0.0"
dependencies = [
"anyhow",
"git2",
"gitbutler-commit",
]
[[package]]
name = "gitbutler-cli"
version = "0.0.0"
@ -2235,6 +2245,7 @@ dependencies = [
"bstr",
"diffy",
"git2",
"gitbutler-cherry-pick",
"gitbutler-command-context",
"gitbutler-serde",
"hex",
@ -2252,14 +2263,17 @@ dependencies = [
"git2",
"gitbutler-branch",
"gitbutler-branch-actions",
"gitbutler-cherry-pick",
"gitbutler-command-context",
"gitbutler-commit",
"gitbutler-diff",
"gitbutler-operating-modes",
"gitbutler-oplog",
"gitbutler-project",
"gitbutler-reference",
"gitbutler-repo",
"gitbutler-time",
"serde",
]
[[package]]
@ -2419,6 +2433,7 @@ dependencies = [
"bstr",
"git2",
"gitbutler-branch",
"gitbutler-cherry-pick",
"gitbutler-command-context",
"gitbutler-commit",
"gitbutler-config",

View File

@ -30,6 +30,7 @@ members = [
"crates/gitbutler-diff",
"crates/gitbutler-operating-modes",
"crates/gitbutler-edit-mode",
"crates/gitbutler-cherry-pick",
]
resolver = "2"
@ -82,6 +83,7 @@ gitbutler-url = { path = "crates/gitbutler-url" }
gitbutler-diff = { path = "crates/gitbutler-diff" }
gitbutler-operating-modes = { path = "crates/gitbutler-operating-modes" }
gitbutler-edit-mode = { path = "crates/gitbutler-edit-mode" }
gitbutler-cherry-pick = { path = "crates/gitbutler-cherry-pick" }
[profile.release]
codegen-units = 1 # Compile crates one after another so the compiler can optimize better

View File

@ -4,8 +4,12 @@
import newProjectSvg from '$lib/assets/illustrations/new-project.svg?raw';
import { Project } from '$lib/backend/projects';
import { ModeService, type EditModeMetadata } from '$lib/modes/service';
import { UncommitedFilesWatcher } from '$lib/uncommitedFiles/watcher';
import { getContext } from '$lib/utils/context';
import Button from '@gitbutler/ui/Button.svelte';
import FileListItem from '@gitbutler/ui/file/FileListItem.svelte';
import type { RemoteFile } from '$lib/vbranches/types';
import type { FileStatus } from '@gitbutler/ui/file/types';
interface Props {
editModeMetadata: EditModeMetadata;
@ -14,16 +18,95 @@
const { editModeMetadata }: Props = $props();
const project = getContext(Project);
const uncommitedFileWatcher = getContext(UncommitedFilesWatcher);
const modeService = getContext(ModeService);
const uncommitedFiles = uncommitedFileWatcher.uncommitedFiles;
let modeServiceAborting = $state<'inert' | 'loading' | 'completed'>('inert');
let modeServiceSaving = $state<'inert' | 'loading' | 'completed'>('inert');
let initialFiles = $state<RemoteFile[]>([]);
$effect(() => {
modeService.getInitialIndexState().then((files) => {
initialFiles = files;
});
});
interface FileEntry {
name: string;
path: string;
conflicted: boolean;
status?: FileStatus;
}
const files = $derived.by(() => {
const initialFileMap = new Map<string, RemoteFile>();
const uncommitedFileMap = new Map<string, RemoteFile>();
const outputMap = new Map<string, FileEntry>();
// Build maps of files
{
initialFiles.forEach((initialFile) => {
initialFileMap.set(initialFile.path, initialFile);
});
$uncommitedFiles.forEach(([uncommitedFile]) => {
uncommitedFileMap.set(uncommitedFile.path, uncommitedFile);
});
}
// Create output
{
initialFiles.forEach((initialFile) => {
const isDeleted = uncommitedFileMap.has(initialFile.path);
outputMap.set(initialFile.path, {
name: initialFile.filename,
path: initialFile.path,
conflicted: initialFile.looksConflicted,
status: isDeleted ? undefined : 'D'
});
});
$uncommitedFiles.forEach(([uncommitedFile]) => {
const initialFile = initialFileMap.get(uncommitedFile.path);
if (initialFile) {
const fileChanged = initialFile.hunks.some(
(hunk) => !uncommitedFile.hunks.map((hunk) => hunk.diff).includes(hunk.diff)
);
if (fileChanged && !uncommitedFile.looksConflicted) {
// All initial entries should have been added to the map,
// so we can safly assert that it will be present
const outputFile = outputMap.get(uncommitedFile.path)!;
outputFile.status = 'M';
outputFile.conflicted = false;
}
} else {
outputMap.set(uncommitedFile.path, {
name: uncommitedFile.filename,
path: uncommitedFile.path,
conflicted: false,
status: 'A'
});
}
});
}
const files = Array.from(outputMap.values());
files.sort((a, b) => a.path.localeCompare(b.path));
return files;
});
async function abort() {
modeServiceSaving = 'loading';
modeServiceAborting = 'loading';
await modeService.abortEditAndReturnToWorkspace();
modeServiceSaving = 'completed';
modeServiceAborting = 'completed';
}
async function save() {
@ -51,8 +134,23 @@
actions.
</p>
<div class="files">
<p class="text-13 text-semibold header">Commit files</p>
{#each files as file}
<div class="file">
<FileListItem
fileName={file.name}
filePath={file.path}
fileStatus={file.status}
conflicted={file.conflicted}
fileStatusStyle={'full'}
/>
</div>
{/each}
</div>
<div class="switchrepo__actions">
<Button style="ghost" outline onclick={abort} loading={modeServiceSaving === 'loading'}>
<Button style="ghost" outline onclick={abort} loading={modeServiceAborting === 'loading'}>
Cancel changes
</Button>
<Button
@ -89,4 +187,28 @@
flex-wrap: wrap;
justify-content: flex-end;
}
.files {
border: 1px solid var(--clr-border-2);
border-radius: var(--radius-m);
margin-bottom: 16px;
overflow: hidden;
padding-bottom: 8px;
& .header {
margin-left: 16px;
margin-top: 16px;
margin-bottom: 8px;
}
& .file {
border-bottom: 1px solid var(--clr-border-3);
&:last-child {
border-bottom: none;
}
}
}
</style>

View File

@ -1,4 +1,6 @@
import { invoke, listen } from '$lib/backend/ipc';
import { RemoteFile } from '$lib/vbranches/types';
import { plainToInstance } from 'class-transformer';
import { derived, writable } from 'svelte/store';
export interface EditModeMetadata {
@ -60,6 +62,13 @@ export class ModeService {
projectId: this.projectId
});
}
async getInitialIndexState() {
return plainToInstance(
RemoteFile,
await invoke<unknown[]>('edit_initial_index_state', { projectId: this.projectId })
);
}
}
function subscribeToHead(projectId: string, callback: (headAndMode: HeadAndMode) => void) {

View File

@ -0,0 +1,48 @@
import { listen } from '$lib/backend/ipc';
import { parseRemoteFiles } from '$lib/vbranches/remoteCommits';
import { RemoteFile } from '$lib/vbranches/types';
import { invoke } from '@tauri-apps/api/tauri';
import { plainToInstance } from 'class-transformer';
import { readable, type Readable } from 'svelte/store';
import type { Project } from '$lib/backend/projects';
import type { ContentSection, HunkSection } from '$lib/utils/fileSections';
type ParsedFiles = [RemoteFile, (ContentSection | HunkSection)[]][];
export class UncommitedFilesWatcher {
uncommitedFiles: Readable<ParsedFiles>;
constructor(private project: Project) {
this.uncommitedFiles = readable([] as ParsedFiles, (set) => {
this.getUncommitedFiles().then((files) => {
set(files);
});
const unsubscribe = this.listen(set);
return unsubscribe;
});
}
private async getUncommitedFiles() {
const uncommitedFiles = await invoke<unknown[]>('get_uncommited_files', {
id: this.project.id
});
const orderedFiles = plainToInstance(RemoteFile, uncommitedFiles).sort((a, b) =>
a.path?.localeCompare(b.path)
);
return parseRemoteFiles(orderedFiles);
}
private listen(callback: (files: ParsedFiles) => void) {
return listen<unknown[]>(`project://${this.project.id}/uncommited-files`, (event) => {
const orderedFiles = plainToInstance(RemoteFile, event.payload).sort((a, b) =>
a.path?.localeCompare(b.path)
);
callback(parseRemoteFiles(orderedFiles));
});
}
}

View File

@ -80,6 +80,10 @@ export class LocalFile {
.filter(notNull)
.filter(isDefined);
}
get looksConflicted(): boolean {
return fileLooksConflicted(this);
}
}
export class SkippedFile {
@ -292,6 +296,22 @@ export class RemoteFile {
get locked(): boolean {
return false;
}
get looksConflicted(): boolean {
return fileLooksConflicted(this);
}
}
function fileLooksConflicted(file: AnyFile) {
const hasStartingMarker = file.hunks.some((hunk) =>
hunk.diff.split('\n').some((line) => line.startsWith('>>>>>>> theirs', 1))
);
const hasEndingMarker = file.hunks.some((hunk) =>
hunk.diff.split('\n').some((line) => line.startsWith('<<<<<<< ours', 1))
);
return hasStartingMarker && hasEndingMarker;
}
export interface Author {

View File

@ -26,6 +26,7 @@
import Navigation from '$lib/navigation/Navigation.svelte';
import { persisted } from '$lib/persisted/persisted';
import { RemoteBranchService } from '$lib/stores/remoteBranches';
import { UncommitedFilesWatcher } from '$lib/uncommitedFiles/watcher';
import { parseRemoteUrl } from '$lib/url/gitUrl';
import { debounce } from '$lib/utils/debounce';
import { BranchController } from '$lib/vbranches/branchController';
@ -73,6 +74,7 @@
setContext(RemoteBranchService, data.remoteBranchService);
setContext(BranchListingService, data.branchListingService);
setContext(ModeService, data.modeService);
setContext(UncommitedFilesWatcher, data.uncommitedFileWatcher);
});
let intervalId: any;

View File

@ -9,6 +9,7 @@ import { HistoryService } from '$lib/history/history';
import { ProjectMetrics } from '$lib/metrics/projectMetrics';
import { ModeService } from '$lib/modes/service';
import { RemoteBranchService } from '$lib/stores/remoteBranches';
import { UncommitedFilesWatcher } from '$lib/uncommitedFiles/watcher';
import { BranchController } from '$lib/vbranches/branchController';
import { VirtualBranchService } from '$lib/vbranches/virtualBranch';
import { error } from '@sveltejs/kit';
@ -76,6 +77,8 @@ export const load: LayoutLoad = async ({ params, parent }) => {
const commitDragActionsFactory = new CommitDragActionsFactory(branchController, project);
const reorderDropzoneManagerFactory = new ReorderDropzoneManagerFactory(branchController);
const uncommitedFileWatcher = new UncommitedFilesWatcher(project);
return {
authService,
baseBranchService,
@ -93,6 +96,7 @@ export const load: LayoutLoad = async ({ params, parent }) => {
branchDragActionsFactory,
commitDragActionsFactory,
reorderDropzoneManagerFactory,
branchListingService
branchListingService,
uncommitedFileWatcher
};
};

View File

@ -25,6 +25,7 @@ gitbutler-url.workspace = true
gitbutler-fs.workspace = true
gitbutler-diff.workspace = true
gitbutler-operating-modes.workspace = true
gitbutler-cherry-pick.workspace = true
serde = { workspace = true, features = ["std"] }
bstr.workspace = true
diffy = "0.4.0"
@ -56,4 +57,4 @@ benches = ["gitbutler-git/benches"]
[[bench]]
name = "branches"
harness = false
harness = false

View File

@ -17,6 +17,7 @@ use crate::{
get_base_branch_data, set_base_branch, set_target_push_remote, update_base_branch,
BaseBranch,
},
branch::get_uncommited_files,
branch_manager::BranchManagerExt,
file::RemoteBranchFile,
remote::{get_branch_data, list_remote_branches, RemoteBranch, RemoteBranchData},
@ -539,6 +540,14 @@ impl VirtualBranchActions {
.create_virtual_branch_from_branch(branch, remote, guard.write_permission())
.map_err(Into::into)
}
pub fn get_uncommited_files(&self, project: &Project) -> Result<Vec<RemoteBranchFile>> {
let context = CommandContext::open(project)?;
let guard = project.exclusive_worktree_access();
get_uncommited_files(&context, guard.read_permission())
}
}
fn open_with_verify(project: &Project) -> Result<CommandContext> {

View File

@ -1,4 +1,4 @@
use crate::VirtualBranchesExt;
use crate::{RemoteBranchFile, VirtualBranchesExt};
use anyhow::{bail, Context, Result};
use bstr::{BStr, ByteSlice};
use core::fmt;
@ -6,6 +6,7 @@ use gitbutler_branch::{
Branch as GitButlerBranch, BranchId, BranchIdentity, ReferenceExtGix, Target,
};
use gitbutler_command_context::{CommandContext, GixRepositoryExt};
use gitbutler_project::access::WorktreeReadPermission;
use gitbutler_reference::normalize_branch_name;
use gitbutler_serde::BStringForFrontend;
use gix::object::tree::diff::Action;
@ -21,6 +22,33 @@ use std::{
vec,
};
pub(crate) fn get_uncommited_files(
context: &CommandContext,
_permission: &WorktreeReadPermission,
) -> Result<Vec<RemoteBranchFile>> {
let repository = context.repository();
let head_commit = repository
.head()
.context("Failed to get head")?
.peel_to_commit()
.context("Failed to get head commit")?;
let files = gitbutler_diff::workdir(repository, &head_commit.id())
.context("Failed to list uncommited files")?
.into_iter()
.map(|(path, file)| {
let binary = file.hunks.iter().any(|h| h.binary);
RemoteBranchFile {
path,
hunks: file.hunks,
binary,
}
})
.collect();
Ok(files)
}
/// Returns a list of branches associated with this project.
pub fn list_branches(
ctx: &CommandContext,

View File

@ -4,9 +4,9 @@ use std::{
};
use anyhow::{anyhow, Context, Result};
use gitbutler_cherry_pick::RepositoryExt as _;
use gitbutler_command_context::CommandContext;
use gitbutler_diff::FileDiff;
use gitbutler_repo::RepositoryExt;
use serde::Serialize;
use crate::{

View File

@ -6,6 +6,7 @@ use gitbutler_branch::{
self, Branch, BranchCreateRequest, SignaturePurpose, VirtualBranchesHandle,
GITBUTLER_INTEGRATION_REFERENCE,
};
use gitbutler_cherry_pick::RepositoryExt as _;
use gitbutler_command_context::CommandContext;
use gitbutler_commit::commit_ext::CommitExt;
use gitbutler_error::error::Marker;

View File

@ -5,11 +5,11 @@ use git2::Tree;
use gitbutler_branch::{
Branch, BranchCreateRequest, BranchId, BranchOwnershipClaims, OwnershipClaim,
};
use gitbutler_cherry_pick::RepositoryExt as _;
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,

View File

@ -12,6 +12,7 @@ use gitbutler_branch::{
dedup, dedup_fmt, reconcile_claims, signature, Branch, BranchId, BranchOwnershipClaims,
BranchUpdateRequest, OwnershipClaim, SignaturePurpose, Target, VirtualBranchesHandle,
};
use gitbutler_cherry_pick::RepositoryExt as _;
use gitbutler_command_context::CommandContext;
use gitbutler_commit::{commit_ext::CommitExt, commit_headers::HasCommitHeaders};
use gitbutler_diff::{trees, GitHunk, Hunk};

View File

@ -0,0 +1,11 @@
[package]
name = "gitbutler-cherry-pick"
version = "0.0.0"
edition = "2021"
authors = ["GitButler <gitbutler@gitbutler.com>"]
publish = false
[dependencies]
gitbutler-commit.workspace = true
git2.workspace = true
anyhow.workspace = true

View File

@ -0,0 +1,104 @@
// 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;
use anyhow::Context;
use gitbutler_commit::commit_ext::CommitExt;
#[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",
}
}
}
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>;
}
impl RepositoryExt for git2::Repository {
/// 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() {
// Use to_rebase's recorded base
self.find_real_tree(to_rebase, ConflictedTreeKey::Base)?
} else {
let base_commit = to_rebase.parent(0)?;
// Use the parent's auto-resolution
self.find_real_tree(&base_commit, Default::default())?
};
// Get the auto-resolution
let ours = self.find_real_tree(head, Default::default())?;
// 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")
}
}
}

View File

@ -14,8 +14,9 @@ hex = "0.4.3"
tracing.workspace = true
gitbutler-serde.workspace = true
gitbutler-command-context.workspace = true
gitbutler-cherry-pick.workspace = true
diffy = "0.4.0"
serde = { workspace = true, features = ["std"]}
serde = { workspace = true, features = ["std"] }
[[test]]
name = "diff"

View File

@ -7,6 +7,7 @@ use std::{
use anyhow::{Context, Result};
use bstr::{BStr, BString, ByteSlice, ByteVec};
use gitbutler_cherry_pick::RepositoryExt;
use gitbutler_serde::BStringForFrontend;
use serde::{Deserialize, Serialize};
use tracing::instrument;
@ -130,7 +131,7 @@ pub fn workdir(repo: &git2::Repository, commit_oid: &git2::Oid) -> Result<DiffBy
let commit = repo
.find_commit(*commit_oid)
.context("failed to find commit")?;
let old_tree = commit.tree().context("failed to find tree")?;
let old_tree = repo.find_real_tree(&commit, Default::default())?;
let mut workdir_index = repo.index()?;

View File

@ -19,3 +19,6 @@ gitbutler-branch-actions.workspace = true
gitbutler-reference.workspace = true
gitbutler-time.workspace = true
gitbutler-oplog.workspace = true
gitbutler-diff.workspace = true
gitbutler-cherry-pick.workspace = true
serde.workspace = true

View File

@ -1,4 +1,5 @@
use anyhow::{Context, Result};
use gitbutler_branch_actions::RemoteBranchFile;
use gitbutler_command_context::CommandContext;
use gitbutler_operating_modes::{assure_edit_mode, assure_open_workspace_mode, EditModeMetadata};
use gitbutler_oplog::{
@ -60,6 +61,14 @@ pub fn abort_and_return_to_workspace(project: &Project) -> Result<()> {
crate::abort_and_return_to_workspace(&ctx, guard.write_permission())
}
pub fn starting_index_state(project: &Project) -> Result<Vec<RemoteBranchFile>> {
let (ctx, guard) = open_with_permission(project)?;
assure_edit_mode(&ctx)?;
crate::starting_index_state(&ctx, guard.read_permission())
}
fn open_with_permission(project: &Project) -> Result<(CommandContext, WriteWorkspaceGuard)> {
let ctx = CommandContext::open(project)?;
let guard = project.exclusive_worktree_access();

View File

@ -4,17 +4,21 @@ use anyhow::{bail, Context, Result};
use bstr::ByteSlice;
use git2::build::CheckoutBuilder;
use gitbutler_branch::{signature, Branch, SignaturePurpose, VirtualBranchesHandle};
use gitbutler_branch_actions::{list_virtual_branches, update_gitbutler_integration};
use gitbutler_branch_actions::{
list_virtual_branches, update_gitbutler_integration, RemoteBranchFile,
};
use gitbutler_cherry_pick::RepositoryExt as _;
use gitbutler_command_context::CommandContext;
use gitbutler_commit::{
commit_ext::CommitExt,
commit_headers::{CommitHeadersV2, HasCommitHeaders},
};
use gitbutler_diff::hunks_by_filepath;
use gitbutler_operating_modes::{
read_edit_mode_metadata, write_edit_mode_metadata, EditModeMetadata, EDIT_BRANCH_REF,
INTEGRATION_BRANCH_REF,
operating_mode, read_edit_mode_metadata, write_edit_mode_metadata, EditModeMetadata,
OperatingMode, EDIT_BRANCH_REF, INTEGRATION_BRANCH_REF,
};
use gitbutler_project::access::WorktreeWritePermission;
use gitbutler_project::access::{WorktreeReadPermission, WorktreeWritePermission};
use gitbutler_reference::{ReferenceName, Refname};
use gitbutler_repo::{
rebase::{cherry_rebase, cherry_rebase_group},
@ -24,6 +28,7 @@ use gitbutler_repo::{
pub mod commands;
pub const EDIT_UNCOMMITED_FILES_REF: &str = "refs/gitbutler/edit_uncommited_files";
pub const EDIT_INITIAL_STATE_REF: &str = "refs/gitbutler/edit_initial_state";
fn save_uncommited_files(ctx: &CommandContext) -> Result<()> {
let repository = ctx.repository();
@ -66,21 +71,7 @@ fn save_uncommited_files(ctx: &CommandContext) -> Result<()> {
Ok(())
}
fn checkout_edit_branch(ctx: &CommandContext, commit: &git2::Commit) -> Result<()> {
let repository = ctx.repository();
// Checkout commits's parent
let commit_parent = commit.parent(0).context("Failed to get commit's parent")?;
repository
.reference(EDIT_BRANCH_REF, commit_parent.id(), true, "")
.context("Failed to update edit branch reference")?;
repository
.set_head(EDIT_BRANCH_REF)
.context("Failed to set head reference")?;
repository
.checkout_head(Some(CheckoutBuilder::new().force().remove_untracked(true)))
.context("Failed to checkout head")?;
fn get_commit_index(repository: &git2::Repository, commit: &git2::Commit) -> Result<git2::Index> {
let commit_tree = commit.tree().context("Failed to get commit's tree")?;
// Checkout the commit as unstaged changes
if commit.is_conflicted() {
@ -105,29 +96,60 @@ fn checkout_edit_branch(ctx: &CommandContext, commit: &git2::Commit) -> Result<(
.find_tree(theirs.id())
.context("Failed to find base tree")?;
let mut index = repository
let 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")?;
Ok(index)
} else {
repository
.checkout_tree(
commit_tree.as_object(),
Some(CheckoutBuilder::new().force().remove_untracked(true)),
)
.context("Failed to checkout commit")?;
};
let mut index = git2::Index::new()?;
index
.read_tree(&commit_tree)
.context("Failed to set index tree")?;
Ok(index)
}
}
fn checkout_edit_branch(ctx: &CommandContext, commit: &git2::Commit) -> Result<()> {
let repository = ctx.repository();
// Checkout commits's parent
let commit_parent = commit.parent(0)?;
repository.reference(EDIT_BRANCH_REF, commit_parent.id(), true, "")?;
repository.reference(EDIT_INITIAL_STATE_REF, commit_parent.id(), true, "")?;
repository.set_head(EDIT_BRANCH_REF)?;
repository.checkout_head(Some(CheckoutBuilder::new().force().remove_untracked(true)))?;
// Checkout the commit as unstaged changes
let mut index = get_commit_index(repository, commit)?;
repository.checkout_index(
Some(&mut index),
Some(
CheckoutBuilder::new()
.force()
.remove_untracked(true)
.conflict_style_diff3(true),
),
)?;
let mut index = repository.index()?;
index.add_all(["*"], git2::IndexAddOption::DEFAULT, None)?;
let tree = index.write_tree()?;
let tree = repository.find_tree(tree)?;
let author_signature = signature(SignaturePurpose::Author)?;
let committer_signature = signature(SignaturePurpose::Committer)?;
// Commit initial state commit
repository.commit(
Some(EDIT_INITIAL_STATE_REF),
&author_signature,
&committer_signature,
"Initial state commit",
&tree,
&[&commit_parent],
)?;
Ok(())
}
@ -331,3 +353,39 @@ pub(crate) fn save_and_return_to_workspace(
Ok(())
}
pub(crate) fn starting_index_state(
ctx: &CommandContext,
_perm: &WorktreeReadPermission,
) -> Result<Vec<RemoteBranchFile>> {
let OperatingMode::Edit(metadata) = operating_mode(ctx) else {
bail!("Starting index state can only be fetched while in edit mode")
};
let repository = ctx.repository();
let commit = repository.find_commit(metadata.commit_oid)?;
let commit_parent = commit.parent(0)?;
let commit_parent_tree = repository.find_real_tree(&commit_parent, Default::default())?;
let initial_state = repository
.find_reference(EDIT_INITIAL_STATE_REF)?
.peel_to_tree()?;
let diff =
repository.diff_tree_to_tree(Some(&commit_parent_tree), Some(&initial_state), None)?;
let diff_files = hunks_by_filepath(Some(repository), &diff)?
.into_iter()
.map(|(path, file)| {
let binary = file.hunks.iter().any(|h| h.binary);
RemoteBranchFile {
path,
hunks: file.hunks,
binary,
}
})
.collect();
Ok(diff_files)
}

View File

@ -34,6 +34,7 @@ gitbutler-id.workspace = true
gitbutler-time.workspace = true
gitbutler-commit.workspace = true
gitbutler-url.workspace = true
gitbutler-cherry-pick.workspace = true
uuid.workspace = true
[[test]]

View File

@ -1,36 +0,0 @@
// 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

@ -17,4 +17,4 @@ pub use config::Config;
pub mod askpass;
mod conflicts;
pub mod temporary_workdir;

View File

@ -1,16 +1,15 @@
use anyhow::{anyhow, Context, Result};
use bstr::ByteSlice;
use git2::{build::TreeUpdateBuilder, Repository};
use git2::build::TreeUpdateBuilder;
use gitbutler_cherry_pick::{ConflictedTreeKey, RepositoryExt};
use gitbutler_command_context::CommandContext;
use gitbutler_commit::{
commit_ext::CommitExt,
commit_headers::{CommitHeadersV2, HasCommitHeaders},
};
use gitbutler_error::error::Marker;
use tempfile::tempdir;
use uuid::Uuid;
use crate::{conflicts::ConflictedTreeKey, LogUntil, RepoActionsExt, RepositoryExt};
use crate::{temporary_workdir::TemporaryWorkdir, LogUntil, RepoActionsExt};
/// cherry-pick based rebase, which handles empty commits
/// this function takes a commit range and generates a Vector of commit oids
@ -111,17 +110,17 @@ fn commit_unconflicted_cherry_result<'repository>(
..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")?;
let commit_oid = crate::RepositoryExt::commit_with_signature(
repository,
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)
@ -153,19 +152,10 @@ fn commit_conflicted_cherry_result<'repository>(
// 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")?;
let temporary_workdir = TemporaryWorkdir::open(repository)?;
worktree_repository
temporary_workdir
.repository()
.set_index(&mut cherrypick_index)
.context("Failed to set cherrypick index as worktree index")?;
@ -246,28 +236,19 @@ fn commit_conflicted_cherry_result<'repository>(
});
// 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()?;
}
let commit_oid = crate::RepositoryExt::commit_with_signature(
repository,
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")?;
repository
.find_commit(commit_oid)

View File

@ -7,30 +7,16 @@ 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_ext::CommitExt, commit_headers::CommitHeadersV2,
};
use gitbutler_commit::{commit_buffer::CommitBuffer, 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.
@ -389,56 +375,6 @@ 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() {
// Use to_rebase's recorded base
self.find_real_tree(to_rebase, ConflictedTreeKey::Base)?
} else {
let base_commit = to_rebase.parent(0)?;
// Use the parent's auto-resolution
self.find_real_tree(&base_commit, Default::default())?
};
// Get the auto-resolution
let ours = self.find_real_tree(head, Default::default())?;
// 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.

View File

@ -0,0 +1,69 @@
use std::path::PathBuf;
use anyhow::{Context, Result};
use tempfile::tempdir;
use uuid::Uuid;
/// A temporary workdir created in a temporary directory
///
/// Gets cleaned up on Drop
/// Drop can panic
pub struct TemporaryWorkdir {
directory: PathBuf,
worktree: git2::Worktree,
repository: git2::Repository,
branch_name: Uuid,
cleaned_up: bool,
}
impl TemporaryWorkdir {
pub fn open(repository: &git2::Repository) -> Result<Self> {
let directory = tempdir().context("Failed to create temporary directory")?;
// By using into path, we need to deconstruct the TempDir ourselves
let path = directory.into_path();
let branch_name = Uuid::new_v4();
let worktree = repository
.worktree(&branch_name.to_string(), &path.join("repository"), None)
.context("Failed to create worktree")?;
let worktree_repository = git2::Repository::open_from_worktree(&worktree)
.context("Failed to open worktree repository")?;
Ok(TemporaryWorkdir {
repository: worktree_repository,
directory: path,
worktree,
branch_name,
cleaned_up: false,
})
}
pub fn repository(&self) -> &git2::Repository {
if self.cleaned_up {
panic!("Can not access repository after its been closed")
}
&self.repository
}
pub fn close(&mut self) -> Result<()> {
if self.cleaned_up {
return Ok(());
}
std::fs::remove_dir_all(&self.directory)?;
self.worktree.prune(None)?;
self.repository
.find_branch(&self.branch_name.to_string(), git2::BranchType::Local)?
.delete()?;
self.cleaned_up = true;
Ok(())
}
}
impl Drop for TemporaryWorkdir {
fn drop(&mut self) {
self.close().expect("Failed to close temporary workdir")
}
}

View File

@ -148,6 +148,7 @@ fn main() {
repo::commands::git_set_local_config,
repo::commands::check_signing_settings,
repo::commands::git_clone_repository,
repo::commands::get_uncommited_files,
virtual_branches::commands::list_virtual_branches,
virtual_branches::commands::create_virtual_branch,
virtual_branches::commands::delete_local_branch,
@ -198,7 +199,8 @@ fn main() {
modes::operating_mode,
modes::enter_edit_mode,
modes::save_edit_and_return_to_workspace,
modes::abort_edit_and_return_to_workspace
modes::abort_edit_and_return_to_workspace,
modes::edit_initial_index_state
])
.menu(menu::build(tauri_context.package_info()))
.on_menu_event(|event| menu::handle_event(&event))

View File

@ -1,4 +1,5 @@
use anyhow::Context;
use gitbutler_branch_actions::RemoteBranchFile;
use gitbutler_operating_modes::EditModeMetadata;
use gitbutler_operating_modes::OperatingMode;
use gitbutler_project::Controller;
@ -55,3 +56,14 @@ pub fn save_edit_and_return_to_workspace(
gitbutler_edit_mode::commands::save_and_return_to_workspace(&project).map_err(Into::into)
}
#[tauri::command(async)]
#[instrument(skip(projects), err(Debug))]
pub fn edit_initial_index_state(
projects: State<'_, Controller>,
project_id: ProjectId,
) -> Result<Vec<RemoteBranchFile>, Error> {
let project = projects.get(project_id)?;
gitbutler_edit_mode::commands::starting_index_state(&project).map_err(Into::into)
}

View File

@ -1,5 +1,6 @@
pub mod commands {
use anyhow::{Context, Result};
use gitbutler_branch_actions::{RemoteBranchFile, VirtualBranchActions};
use gitbutler_project as projects;
use gitbutler_project::ProjectId;
use gitbutler_repo::RepoCommands;
@ -60,4 +61,14 @@ pub mod commands {
.context("Failed to checkout main worktree")?;
Ok(())
}
#[tauri::command(async)]
pub fn get_uncommited_files(
projects: State<'_, projects::Controller>,
id: ProjectId,
) -> Result<Vec<RemoteBranchFile>, Error> {
let project = projects.get(id)?;
Ok(VirtualBranchActions.get_uncommited_files(&project)?)
}
}

View File

@ -52,6 +52,11 @@ pub(super) mod state {
payload: serde_json::json!(virtual_branches),
project_id,
},
Change::UncommitedFiles { project_id, files } => ChangeForFrontend {
name: format!("project://{}/uncommited-files", project_id),
payload: serde_json::json!(files),
project_id,
},
}
}
}

View File

@ -1,6 +1,6 @@
use std::{fmt::Display, path::PathBuf};
use gitbutler_branch_actions::VirtualBranches;
use gitbutler_branch_actions::{RemoteBranchFile, VirtualBranches};
use gitbutler_operating_modes::OperatingMode;
use gitbutler_project::ProjectId;
@ -101,4 +101,8 @@ pub enum Change {
project_id: ProjectId,
virtual_branches: VirtualBranches,
},
UncommitedFiles {
project_id: ProjectId,
files: Vec<RemoteBranchFile>,
},
}

View File

@ -11,8 +11,8 @@ use gitbutler_oplog::{
entry::{OperationKind, SnapshotDetails},
OplogExt,
};
use gitbutler_project as projects;
use gitbutler_project::ProjectId;
use gitbutler_project::{self as projects, Project};
use gitbutler_reference::{LocalRefname, Refname};
use gitbutler_sync::cloud::{push_oplog, push_repo};
use gitbutler_user as users;
@ -125,16 +125,29 @@ impl Handler {
#[instrument(skip(self, paths, project_id), fields(paths = paths.len()))]
fn recalculate_everything(&self, paths: Vec<PathBuf>, project_id: ProjectId) -> Result<()> {
let ctx = self.open_command_context(project_id)?;
// Skip if we're not on the open workspace mode
if !in_open_workspace_mode(&ctx) {
return Ok(());
self.emit_uncommited_files(ctx.project());
if in_open_workspace_mode(&ctx) {
self.maybe_create_snapshot(project_id).ok();
self.calculate_virtual_branches(project_id)?;
}
self.maybe_create_snapshot(project_id).ok();
self.calculate_virtual_branches(project_id)?;
Ok(())
}
/// Try to emit uncommited files. Swollow errors if they arrise.
fn emit_uncommited_files(&self, project: &Project) {
let Ok(files) = VirtualBranchActions.get_uncommited_files(project) else {
return;
};
let _ = self.emit_app_event(Change::UncommitedFiles {
project_id: project.id,
files,
});
}
fn maybe_create_snapshot(&self, project_id: ProjectId) -> anyhow::Result<()> {
let project = self
.projects