Merge pull request #3608 from gitbutlerapp/implement-mvp-of-undo-stack

implement mvp of undo stack
This commit is contained in:
Kiril Videlov 2024-04-25 22:00:34 +02:00 committed by GitHub
commit b1d03995b7
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 575 additions and 30 deletions

View File

@ -0,0 +1,79 @@
<script lang="ts">
import Button from './Button.svelte';
import { invoke } from '$lib/backend/ipc';
import { getContext } from '$lib/utils/context';
import { toHumanReadableTime } from '$lib/utils/time';
import { VirtualBranchService } from '$lib/vbranches/virtualBranch';
import { onMount } from 'svelte';
export let projectId: string;
const snapshotsLimit = 30;
const vbranchService = getContext(VirtualBranchService);
vbranchService.activeBranches.subscribe(() => {
listSnapshots(projectId, snapshotsLimit);
});
type SnapshotEntry = {
sha: string;
label: string;
createdAt: number;
};
let snapshots: SnapshotEntry[] = [];
async function listSnapshots(projectId: string, limit: number) {
const resp = await invoke<SnapshotEntry[]>('list_snapshots', {
projectId: projectId,
limit: limit
});
snapshots = resp;
}
async function restoreSnapshot(projectId: string, sha: string) {
const resp = await invoke<string>('restore_snapshot', {
projectId: projectId,
sha: sha
});
console.log(resp);
}
onMount(async () => {
listSnapshots(projectId, snapshotsLimit);
});
</script>
<div class="container">
{#each snapshots as entry, idx}
<div class="card">
<div class="entry">
<div>
{entry.label}
</div>
<div>
<span>
{toHumanReadableTime(entry.createdAt)}
</span>
{#if idx != 0}
<Button
style="pop"
size="tag"
icon="undo-small"
on:click={async () => await restoreSnapshot(projectId, entry.sha)}>restore</Button
>
{/if}
</div>
</div>
</div>
{/each}
</div>
<style>
.container {
width: 50rem;
padding: 0.5rem;
border-left-width: 1px;
overflow-y: auto;
}
.entry {
flex: auto;
flex-direction: column;
}
</style>

View File

@ -17,6 +17,7 @@ export interface Settings {
zoom: number; zoom: number;
scrollbarVisabilityOnHover: boolean; scrollbarVisabilityOnHover: boolean;
tabSize: number; tabSize: number;
showHistoryView: boolean;
} }
const defaults: Settings = { const defaults: Settings = {
@ -31,7 +32,8 @@ const defaults: Settings = {
stashedBranchesHeight: 150, stashedBranchesHeight: 150,
zoom: 1, zoom: 1,
scrollbarVisabilityOnHover: false, scrollbarVisabilityOnHover: false,
tabSize: 4 tabSize: 4,
showHistoryView: false
}; };
export function loadUserSettings(): Writable<Settings> { export function loadUserSettings(): Writable<Settings> {

View File

@ -67,6 +67,12 @@
hotkeys.on('Backspace', (e) => { hotkeys.on('Backspace', (e) => {
// This prevent backspace from navigating back // This prevent backspace from navigating back
e.preventDefault(); e.preventDefault();
}),
hotkeys.on('$mod+Shift+H', () => {
userSettings.update((s) => ({
...s,
showHistoryView: !$userSettings.showHistoryView
}));
}) })
); );
}); });

View File

@ -2,11 +2,14 @@
import { Project } from '$lib/backend/projects'; import { Project } from '$lib/backend/projects';
import { syncToCloud } from '$lib/backend/sync'; import { syncToCloud } from '$lib/backend/sync';
import { BranchService } from '$lib/branches/service'; import { BranchService } from '$lib/branches/service';
import History from '$lib/components/History.svelte';
import Navigation from '$lib/components/Navigation.svelte'; import Navigation from '$lib/components/Navigation.svelte';
import NoBaseBranch from '$lib/components/NoBaseBranch.svelte'; import NoBaseBranch from '$lib/components/NoBaseBranch.svelte';
import NotOnGitButlerBranch from '$lib/components/NotOnGitButlerBranch.svelte'; import NotOnGitButlerBranch from '$lib/components/NotOnGitButlerBranch.svelte';
import ProblemLoadingRepo from '$lib/components/ProblemLoadingRepo.svelte'; import ProblemLoadingRepo from '$lib/components/ProblemLoadingRepo.svelte';
import ProjectSettingsMenuAction from '$lib/components/ProjectSettingsMenuAction.svelte'; import ProjectSettingsMenuAction from '$lib/components/ProjectSettingsMenuAction.svelte';
import { SETTINGS, type Settings } from '$lib/settings/userSettings';
import { getContextStoreBySymbol } from '$lib/utils/context';
import * as hotkeys from '$lib/utils/hotkeys'; import * as hotkeys from '$lib/utils/hotkeys';
import { unsubscribe } from '$lib/utils/unsubscribe'; import { unsubscribe } from '$lib/utils/unsubscribe';
import { BaseBranchService, NoDefaultTarget } from '$lib/vbranches/baseBranch'; import { BaseBranchService, NoDefaultTarget } from '$lib/vbranches/baseBranch';
@ -33,6 +36,7 @@
$: baseBranch = baseBranchService.base; $: baseBranch = baseBranchService.base;
$: baseError = baseBranchService.error; $: baseError = baseBranchService.error;
$: projectError = projectService.error; $: projectError = projectService.error;
const userSettings = getContextStoreBySymbol<Settings>(SETTINGS);
$: setContext(VirtualBranchService, vbranchService); $: setContext(VirtualBranchService, vbranchService);
$: setContext(BranchController, branchController); $: setContext(BranchController, branchController);
@ -90,6 +94,9 @@
<div class="view-wrap" role="group" on:dragover|preventDefault> <div class="view-wrap" role="group" on:dragover|preventDefault>
<Navigation /> <Navigation />
<slot /> <slot />
{#if $userSettings.showHistoryView}
<History {projectId} />
{/if}
</div> </div>
{/if} {/if}
{/key} {/key}

View File

@ -30,6 +30,7 @@ pub mod project_repository;
pub mod projects; pub mod projects;
pub mod reader; pub mod reader;
pub mod sessions; pub mod sessions;
pub mod snapshots;
pub mod ssh; pub mod ssh;
pub mod storage; pub mod storage;
pub mod types; pub mod types;

View File

@ -82,6 +82,8 @@ pub struct Project {
pub project_data_last_fetch: Option<FetchResult>, pub project_data_last_fetch: Option<FetchResult>,
#[serde(default)] #[serde(default)]
pub omit_certificate_check: Option<bool>, pub omit_certificate_check: Option<bool>,
#[serde(default)]
pub enable_snapshots: Option<bool>,
} }
impl AsRef<Project> for Project { impl AsRef<Project> for Project {

View File

@ -0,0 +1,3 @@
mod reflog;
pub mod snapshot;
mod state;

View File

@ -0,0 +1,99 @@
use crate::storage;
use anyhow::Result;
use gix::tempfile::{AutoRemove, ContainingDirectory};
use itertools::Itertools;
use std::{io::Write, path::PathBuf};
use crate::projects::Project;
/// Sets a reference to the oplog head commit such that snapshots are reachable and will not be garbage collected.
/// We want to achieve 2 things:
/// - The oplog must not be visible in `git log --all` as branch
/// - The oplog tree must not be garbage collected (i.e. it must be reachable)
///
/// This needs to be invoked whenever the target head or the oplog head change.
///
/// How it works:
/// First a reference gitbutler/target is created, pointing to the head of the target (trunk) branch. This is a fake branch that we don't need to care about. If it doesn't exist, it is created.
/// Then in the reflog entry logs/refs/heads/gitbutler/target we pretend that the the ref originally pointed to the oplog head commit like so:
///
/// 0000000000000000000000000000000000000000 <target branch head sha>
/// <target branch head sha> <oplog head sha>
///
/// The reflog entry is continuously updated to refer to the current target and oplog head commits.
pub fn set_reference_to_oplog(
project: &Project,
target_head_sha: &str,
oplog_head_sha: &str,
) -> Result<()> {
let repo_path = project.path.as_path();
let reflog_file_path = repo_path
.join(".git")
.join("logs")
.join("refs")
.join("heads")
.join("gitbutler")
.join("target");
if !reflog_file_path.exists() {
let repo = git2::Repository::init(repo_path)?;
let commit = repo.find_commit(git2::Oid::from_str(target_head_sha)?)?;
repo.branch("gitbutler/target", &commit, false)?;
}
if !reflog_file_path.exists() {
return Err(anyhow::anyhow!(
"Could not create gitbutler/target which is needed for undo snapshotting"
));
}
set_target_ref(&reflog_file_path, target_head_sha)?;
set_oplog_ref(&reflog_file_path, oplog_head_sha)?;
Ok(())
}
fn set_target_ref(file_path: &PathBuf, sha: &str) -> Result<()> {
// 0000000000000000000000000000000000000000 82873b54925ab268e9949557f28d070d388e7774 Kiril Videlov <kiril@videlov.com> 1714037434 +0200 branch: Created from 82873b54925ab268e9949557f28d070d388e7774
let content = std::fs::read_to_string(file_path)?;
let mut lines = content.lines().collect::<Vec<_>>();
let mut first_line = lines[0].split_whitespace().collect_vec();
let len = first_line.len();
first_line[1] = sha;
first_line[len - 1] = sha;
let binding = first_line.join(" ");
lines[0] = &binding;
let content = format!("{}\n", lines.join("\n"));
write(file_path, &content)
}
fn set_oplog_ref(file_path: &PathBuf, sha: &str) -> Result<()> {
// 82873b54925ab268e9949557f28d070d388e7774 7e8eab472636a26611214bebea7d6b79c971fb8b Kiril Videlov <kiril@videlov.com> 1714044124 +0200 reset: moving to 7e8eab472636a26611214bebea7d6b79c971fb8b
let content = std::fs::read_to_string(file_path)?;
let first_line = content.lines().collect::<Vec<_>>().remove(0);
let target_ref = first_line.split_whitespace().collect_vec()[1];
let the_rest = first_line.split_whitespace().collect_vec()[2..].join(" ");
let the_rest = the_rest.replace("branch", " reset");
let mut the_rest_split = the_rest.split(':').collect_vec();
let new_msg = format!(" moving to {}", sha);
the_rest_split[1] = &new_msg;
let the_rest = the_rest_split.join(":");
let second_line = [target_ref, sha, &the_rest].join(" ");
let content = format!("{}\n", [first_line, &second_line].join("\n"));
write(file_path, &content)
}
fn write(file_path: &PathBuf, content: &str) -> Result<()> {
let mut temp_file = gix::tempfile::new(
file_path.parent().unwrap(),
ContainingDirectory::Exists,
AutoRemove::Tempfile,
)?;
temp_file.write_all(content.as_bytes())?;
storage::persist_tempfile(temp_file, file_path)?;
Ok(())
}

View File

@ -0,0 +1,174 @@
use anyhow::Result;
use serde::Serialize;
use crate::{projects::Project, virtual_branches::VirtualBranchesHandle};
use super::{reflog::set_reference_to_oplog, state::OplogHandle};
/// A snapshot of the repository and virtual branches state that GitButler can restore to.
/// It captures the state of the working directory, virtual branches and commits.
#[derive(Debug, PartialEq, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct SnapshotEntry {
/// The sha of the commit that represents the snapshot.
pub sha: String,
/// Textual description of the snapshot.
pub label: String,
/// The time the snapshot was created at in milliseconds since epoch.
pub created_at: i64,
}
/// Creates a snapshot of the current state of the repository and virtual branches using the given label.
///
/// If this is the first shapshot created, supporting structures are initialized:
/// - The current oplog head is persisted in `.git/gitbutler/oplog.toml`.
/// - A fake branch `gitbutler/target` is created and maintained in order to keep the oplog head reachable.
///
/// The state of virtual branches `.git/gitbutler/virtual_branches.toml` is copied to the project root so that it is snapshotted.
pub fn create(project: &Project, label: &str) -> Result<()> {
if project.enable_snapshots.is_none() || project.enable_snapshots == Some(false) {
return Ok(());
}
let repo_path = project.path.as_path();
let repo = git2::Repository::init(repo_path)?;
let vb_state = VirtualBranchesHandle::new(&project.gb_dir());
let default_target_sha = vb_state.get_default_target()?.sha;
let oplog_state = OplogHandle::new(&project.gb_dir());
let oplog_head_commit = match oplog_state.get_oplog_head()? {
Some(head_sha) => match repo.find_commit(git2::Oid::from_str(&head_sha)?) {
Ok(commit) => commit,
Err(_) => repo.find_commit(default_target_sha.into())?,
},
// This is the first snapshot - use the default target as starting point
None => repo.find_commit(default_target_sha.into())?,
};
// Copy virtual_branches.rs to the project root so that we snapshot it
std::fs::copy(
repo_path.join(".git/gitbutler/virtual_branches.toml"),
repo_path.join("virtual_branches.toml"),
)?;
// Add everything in the workdir to the index
let mut index = repo.index()?;
index.add_all(["*"].iter(), git2::IndexAddOption::DEFAULT, None)?;
index.write()?;
// Create a tree out of the index
let tree_id = index.write_tree()?;
let tree = repo.find_tree(tree_id)?;
// Construct a new commit
let signature = repo.signature()?;
let new_commit_oid = repo.commit(
None,
&signature,
&signature,
label,
&tree,
&[&oplog_head_commit],
)?;
// Remove the copied virtual_branches.rs
std::fs::remove_file(project.path.join("virtual_branches.toml"))?;
// Reset the workdir to how it was
let integration_branch = repo
.find_branch("gitbutler/integration", git2::BranchType::Local)?
.get()
.peel_to_commit()?;
repo.reset(
&integration_branch.into_object(),
git2::ResetType::Mixed,
None,
)?;
oplog_state.set_oplog_head(new_commit_oid.to_string())?;
set_reference_to_oplog(
project,
&default_target_sha.to_string(),
&new_commit_oid.to_string(),
)?;
Ok(())
}
/// Lists the snapshots that have been created for the given repository, up to the given limit.
/// An alternative way of retrieving the snapshots would be to manually the oplog head `git log <oplog_head>` available in `.git/gitbutler/oplog.toml`.
///
/// If there are no snapshots, an empty list is returned.
pub fn list(project: Project, limit: usize) -> Result<Vec<SnapshotEntry>> {
let repo_path = project.path.as_path();
let repo = git2::Repository::init(repo_path)?;
let oplog_state = OplogHandle::new(&project.gb_dir());
let head_sha = oplog_state.get_oplog_head()?;
if head_sha.is_none() {
// there are no snapshots to return
return Ok(vec![]);
}
let head_sha = head_sha.unwrap();
let oplog_head_commit = repo.find_commit(git2::Oid::from_str(&head_sha)?)?;
let mut revwalk = repo.revwalk()?;
revwalk.push(oplog_head_commit.id())?;
let mut snapshots = Vec::new();
for commit_id in revwalk {
let commit_id = commit_id?;
let commit = repo.find_commit(commit_id)?;
if commit.parent_count() > 1 {
break;
}
snapshots.push(SnapshotEntry {
sha: commit_id.to_string(),
label: commit.summary().unwrap_or_default().to_string(),
created_at: commit.time().seconds() * 1000,
});
if snapshots.len() >= limit {
break;
}
}
Ok(snapshots)
}
/// Reverts to a previous state of the working directory, virtual branches and commits.
/// The provided sha must refer to a valid snapshot commit.
/// Upon success, a new snapshot is created.
///
/// The state of virtual branches `.git/gitbutler/virtual_branches.toml` is restored from the snapshot.
pub fn restore(project: &Project, sha: String) -> Result<()> {
let repo_path = project.path.as_path();
let repo = git2::Repository::init(repo_path)?;
let commit = repo.find_commit(git2::Oid::from_str(&sha)?)?;
let tree = commit.tree()?;
// Define the checkout builder
let mut checkout_builder = git2::build::CheckoutBuilder::new();
checkout_builder.force();
// Checkout the tree
repo.checkout_tree(tree.as_object(), Some(&mut checkout_builder))?;
// mv virtual_branches.toml from project root to .git/gitbutler
std::fs::rename(
repo_path.join("virtual_branches.toml"),
repo_path.join(".git/gitbutler/virtual_branches.toml"),
)?;
// create new snapshot
let label = format!("Restored from {}", &sha);
create(project, &label)?;
Ok(())
}

View File

@ -0,0 +1,81 @@
use crate::storage;
use anyhow::Result;
use gix::tempfile::{AutoRemove, ContainingDirectory};
use std::{
fs::File,
io::{Read, Write},
path::{Path, PathBuf},
};
use serde::{Deserialize, Serialize};
/// This tracks the head of the oplog, persisted in oplog.toml.
#[derive(Serialize, Deserialize, Debug, Default)]
pub struct Oplog {
/// This is the sha of the last oplog commit
pub head_sha: Option<String>,
}
pub struct OplogHandle {
/// The path to the file containing the oplog head state.
file_path: PathBuf,
}
impl OplogHandle {
/// Creates a new concurrency-safe handle to the state of the oplog.
pub fn new(base_path: &Path) -> Self {
let file_path = base_path.join("oplog.toml");
Self { file_path }
}
/// Persists the oplog head for the given repository.
///
/// Errors if the file cannot be read or written.
pub fn set_oplog_head(&self, sha: String) -> Result<()> {
let mut oplog = self.read_file()?;
oplog.head_sha = Some(sha);
self.write_file(&oplog)?;
Ok(())
}
/// Gets the oplog head sha for the given repository.
///
/// Errors if the file cannot be read or written.
pub fn get_oplog_head(&self) -> anyhow::Result<Option<String>> {
let oplog = self.read_file()?;
Ok(oplog.head_sha)
}
/// Reads and parses the state file.
///
/// If the file does not exist, it will be created.
fn read_file(&self) -> Result<Oplog> {
if !self.file_path.exists() {
return Ok(Oplog::default());
}
let mut file: File = File::open(self.file_path.as_path())?;
let mut contents = String::new();
file.read_to_string(&mut contents)?;
let oplog: Oplog =
toml::from_str(&contents).map_err(|e| crate::reader::Error::ParseError {
path: self.file_path.clone(),
source: e,
})?;
Ok(oplog)
}
fn write_file(&self, oplog: &Oplog) -> anyhow::Result<()> {
write(self.file_path.as_path(), oplog)
}
}
fn write<P: AsRef<Path>>(file_path: P, oplog: &Oplog) -> anyhow::Result<()> {
let contents = toml::to_string(&oplog)?;
let mut temp_file = gix::tempfile::new(
file_path.as_ref().parent().unwrap(),
ContainingDirectory::Exists,
AutoRemove::Tempfile,
)?;
temp_file.write_all(contents.as_bytes())?;
Ok(storage::persist_tempfile(temp_file, file_path)?)
}

View File

@ -1,4 +1,4 @@
use crate::error::Error; use crate::{error::Error, snapshots::snapshot};
use std::{collections::HashMap, path::Path, sync::Arc}; use std::{collections::HashMap, path::Path, sync::Arc};
use anyhow::Context; use anyhow::Context;
@ -397,7 +397,7 @@ impl ControllerInner {
}) })
.transpose()?; .transpose()?;
super::commit( let result = super::commit(
project_repository, project_repository,
branch_id, branch_id,
message, message,
@ -406,7 +406,9 @@ impl ControllerInner {
user, user,
run_hooks, run_hooks,
) )
.map_err(Into::into) .map_err(Into::into);
snapshot::create(project_repository.project(), "create commit")?;
result
}) })
} }
@ -453,6 +455,7 @@ impl ControllerInner {
self.with_verify_branch(project_id, |project_repository, _| { self.with_verify_branch(project_id, |project_repository, _| {
let branch_id = super::create_virtual_branch(project_repository, create)?.id; let branch_id = super::create_virtual_branch(project_repository, create)?.id;
snapshot::create(project_repository.project(), "create branch")?;
Ok(branch_id) Ok(branch_id)
}) })
} }
@ -475,13 +478,14 @@ impl ControllerInner {
.context("failed to get private key") .context("failed to get private key")
}) })
.transpose()?; .transpose()?;
let result = super::create_virtual_branch_from_branch(
Ok(super::create_virtual_branch_from_branch(
project_repository, project_repository,
branch, branch,
signing_key.as_ref(), signing_key.as_ref(),
user, user,
)?) )?;
snapshot::create(project_repository.project(), "create branch")?;
Ok(result)
}) })
} }
@ -512,8 +516,9 @@ impl ControllerInner {
) -> Result<super::BaseBranch, Error> { ) -> Result<super::BaseBranch, Error> {
let project = self.projects.get(project_id)?; let project = self.projects.get(project_id)?;
let project_repository = project_repository::Repository::open(&project)?; let project_repository = project_repository::Repository::open(&project)?;
let result = super::set_base_branch(&project_repository, target_branch)?;
Ok(super::set_base_branch(&project_repository, target_branch)?) snapshot::create(project_repository.project(), "set base branch")?;
Ok(result)
} }
pub async fn merge_virtual_branch_upstream( pub async fn merge_virtual_branch_upstream(
@ -535,13 +540,15 @@ impl ControllerInner {
}) })
.transpose()?; .transpose()?;
super::merge_virtual_branch_upstream( let result = super::merge_virtual_branch_upstream(
project_repository, project_repository,
branch_id, branch_id,
signing_key.as_ref(), signing_key.as_ref(),
user, user,
) )
.map_err(Into::into) .map_err(Into::into);
snapshot::create(project_repository.project(), "merge upstream")?;
result
}) })
} }
@ -560,8 +567,10 @@ impl ControllerInner {
}) })
.transpose()?; .transpose()?;
super::update_base_branch(project_repository, user, signing_key.as_ref()) let result = super::update_base_branch(project_repository, user, signing_key.as_ref())
.map_err(Into::into) .map_err(Into::into);
snapshot::create(project_repository.project(), "update workspace base")?;
result
}) })
} }
@ -573,7 +582,23 @@ impl ControllerInner {
let _permit = self.semaphore.acquire().await; let _permit = self.semaphore.acquire().await;
self.with_verify_branch(project_id, |project_repository, _| { self.with_verify_branch(project_id, |project_repository, _| {
let label = if branch_update.ownership.is_some() {
"move hunk"
} else if branch_update.name.is_some() {
"update branch name"
} else if branch_update.notes.is_some() {
"update branch notes"
} else if branch_update.order.is_some() {
"reorder branches"
} else if branch_update.selected_for_changes.is_some() {
"select default branch"
} else if branch_update.upstream.is_some() {
"update remote branch name"
} else {
"update branch"
};
super::update_branch(project_repository, branch_update)?; super::update_branch(project_repository, branch_update)?;
snapshot::create(project_repository.project(), label)?;
Ok(()) Ok(())
}) })
} }
@ -587,6 +612,7 @@ impl ControllerInner {
self.with_verify_branch(project_id, |project_repository, _| { self.with_verify_branch(project_id, |project_repository, _| {
super::delete_branch(project_repository, branch_id)?; super::delete_branch(project_repository, branch_id)?;
snapshot::create(project_repository.project(), "delete branch")?;
Ok(()) Ok(())
}) })
} }
@ -610,8 +636,11 @@ impl ControllerInner {
}) })
.transpose()?; .transpose()?;
let result =
super::apply_branch(project_repository, branch_id, signing_key.as_ref(), user) super::apply_branch(project_repository, branch_id, signing_key.as_ref(), user)
.map_err(Into::into) .map_err(Into::into);
snapshot::create(project_repository.project(), "apply branch")?;
result
}) })
} }
@ -623,7 +652,10 @@ impl ControllerInner {
let _permit = self.semaphore.acquire().await; let _permit = self.semaphore.acquire().await;
self.with_verify_branch(project_id, |project_repository, _| { self.with_verify_branch(project_id, |project_repository, _| {
super::unapply_ownership(project_repository, ownership).map_err(Into::into) let result =
super::unapply_ownership(project_repository, ownership).map_err(Into::into);
snapshot::create(project_repository.project(), "discard hunk")?;
result
}) })
} }
@ -635,7 +667,9 @@ impl ControllerInner {
let _permit = self.semaphore.acquire().await; let _permit = self.semaphore.acquire().await;
self.with_verify_branch(project_id, |project_repository, _| { self.with_verify_branch(project_id, |project_repository, _| {
super::reset_files(project_repository, ownership).map_err(Into::into) let result = super::reset_files(project_repository, ownership).map_err(Into::into);
snapshot::create(project_repository.project(), "discard file")?;
result
}) })
} }
@ -648,7 +682,9 @@ impl ControllerInner {
let _permit = self.semaphore.acquire().await; let _permit = self.semaphore.acquire().await;
self.with_verify_branch(project_id, |project_repository, _| { self.with_verify_branch(project_id, |project_repository, _| {
super::amend(project_repository, branch_id, ownership).map_err(Into::into) let result = super::amend(project_repository, branch_id, ownership).map_err(Into::into);
snapshot::create(project_repository.project(), "amend commit")?;
result
}) })
} }
@ -661,8 +697,10 @@ impl ControllerInner {
let _permit = self.semaphore.acquire().await; let _permit = self.semaphore.acquire().await;
self.with_verify_branch(project_id, |project_repository, _| { self.with_verify_branch(project_id, |project_repository, _| {
super::reset_branch(project_repository, branch_id, target_commit_oid) let result = super::reset_branch(project_repository, branch_id, target_commit_oid)
.map_err(Into::into) .map_err(Into::into);
snapshot::create(project_repository.project(), "undo commit")?;
result
}) })
} }
@ -674,9 +712,11 @@ impl ControllerInner {
let _permit = self.semaphore.acquire().await; let _permit = self.semaphore.acquire().await;
self.with_verify_branch(project_id, |project_repository, _| { self.with_verify_branch(project_id, |project_repository, _| {
super::unapply_branch(project_repository, branch_id) let result = super::unapply_branch(project_repository, branch_id)
.map(|_| ()) .map(|_| ())
.map_err(Into::into) .map_err(Into::into);
snapshot::create(project_repository.project(), "unapply branch")?;
result
}) })
} }
@ -713,7 +753,10 @@ impl ControllerInner {
let _permit = self.semaphore.acquire().await; let _permit = self.semaphore.acquire().await;
self.with_verify_branch(project_id, |project_repository, _| { self.with_verify_branch(project_id, |project_repository, _| {
super::cherry_pick(project_repository, branch_id, commit_oid).map_err(Into::into) let result =
super::cherry_pick(project_repository, branch_id, commit_oid).map_err(Into::into);
snapshot::create(project_repository.project(), "cherry pick")?;
result
}) })
} }
@ -745,7 +788,10 @@ impl ControllerInner {
let _permit = self.semaphore.acquire().await; let _permit = self.semaphore.acquire().await;
self.with_verify_branch(project_id, |project_repository, _| { self.with_verify_branch(project_id, |project_repository, _| {
super::squash(project_repository, branch_id, commit_oid).map_err(Into::into) let result =
super::squash(project_repository, branch_id, commit_oid).map_err(Into::into);
snapshot::create(project_repository.project(), "squash commit")?;
result
}) })
} }
@ -758,8 +804,11 @@ impl ControllerInner {
) -> Result<(), Error> { ) -> Result<(), Error> {
let _permit = self.semaphore.acquire().await; let _permit = self.semaphore.acquire().await;
self.with_verify_branch(project_id, |project_repository, _| { self.with_verify_branch(project_id, |project_repository, _| {
let result =
super::update_commit_message(project_repository, branch_id, commit_oid, message) super::update_commit_message(project_repository, branch_id, commit_oid, message)
.map_err(Into::into) .map_err(Into::into);
snapshot::create(project_repository.project(), "update commit message")?;
result
}) })
} }
@ -829,14 +878,16 @@ impl ControllerInner {
.context("failed to get private key") .context("failed to get private key")
}) })
.transpose()?; .transpose()?;
super::move_commit( let result = super::move_commit(
project_repository, project_repository,
target_branch_id, target_branch_id,
commit_oid, commit_oid,
user, user,
signing_key.as_ref(), signing_key.as_ref(),
) )
.map_err(Into::into) .map_err(Into::into);
snapshot::create(project_repository.project(), "moved commit")?;
result
}) })
} }
} }

View File

@ -28,6 +28,7 @@ pub mod keys;
pub mod projects; pub mod projects;
pub mod sentry; pub mod sentry;
pub mod sessions; pub mod sessions;
pub mod snapshots;
pub mod users; pub mod users;
pub mod virtual_branches; pub mod virtual_branches;
pub mod zip; pub mod zip;

View File

@ -18,8 +18,8 @@ use std::path::PathBuf;
use anyhow::Context; use anyhow::Context;
use gitbutler_core::{assets, database, git, storage}; use gitbutler_core::{assets, database, git, storage};
use gitbutler_tauri::{ use gitbutler_tauri::{
app, askpass, commands, deltas, github, keys, logs, menu, projects, sentry, sessions, users, app, askpass, commands, deltas, github, keys, logs, menu, projects, sentry, sessions,
virtual_branches, watcher, zip, snapshots, users, virtual_branches, watcher, zip,
}; };
use tauri::{generate_context, Manager, Wry}; use tauri::{generate_context, Manager, Wry};
use tauri_plugin_log::LogTarget; use tauri_plugin_log::LogTarget;
@ -260,6 +260,8 @@ fn main() {
virtual_branches::commands::squash_branch_commit, virtual_branches::commands::squash_branch_commit,
virtual_branches::commands::fetch_from_target, virtual_branches::commands::fetch_from_target,
virtual_branches::commands::move_commit, virtual_branches::commands::move_commit,
snapshots::list_snapshots,
snapshots::restore_snapshot,
menu::menu_item_set_enabled, menu::menu_item_set_enabled,
keys::commands::get_public_key, keys::commands::get_public_key,
github::commands::init_device_oauth, github::commands::init_device_oauth,

View File

@ -0,0 +1,37 @@
use crate::error::Error;
use anyhow::Context;
use gitbutler_core::{
projects, projects::ProjectId, snapshots::snapshot, snapshots::snapshot::SnapshotEntry,
};
use tauri::Manager;
use tracing::instrument;
#[tauri::command(async)]
#[instrument(skip(handle), err(Debug))]
pub async fn list_snapshots(
handle: tauri::AppHandle,
project_id: ProjectId,
limit: usize,
) -> Result<Vec<SnapshotEntry>, Error> {
let project = handle
.state::<projects::Controller>()
.get(&project_id)
.context("failed to get project")?;
let snapshots = snapshot::list(project, limit)?;
Ok(snapshots)
}
#[tauri::command(async)]
#[instrument(skip(handle), err(Debug))]
pub async fn restore_snapshot(
handle: tauri::AppHandle,
project_id: ProjectId,
sha: String,
) -> Result<(), Error> {
let project = handle
.state::<projects::Controller>()
.get(&project_id)
.context("failed to get project")?;
snapshot::restore(&project, sha)?;
Ok(())
}