mirror of
https://github.com/gitbutlerapp/gitbutler.git
synced 2024-12-18 14:31:30 +03:00
Merge pull request #3608 from gitbutlerapp/implement-mvp-of-undo-stack
implement mvp of undo stack
This commit is contained in:
commit
b1d03995b7
79
app/src/lib/components/History.svelte
Normal file
79
app/src/lib/components/History.svelte
Normal 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>
|
@ -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> {
|
||||||
|
@ -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
|
||||||
|
}));
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
@ -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}
|
||||||
|
@ -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;
|
||||||
|
@ -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 {
|
||||||
|
3
crates/gitbutler-core/src/snapshots/mod.rs
Normal file
3
crates/gitbutler-core/src/snapshots/mod.rs
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
mod reflog;
|
||||||
|
pub mod snapshot;
|
||||||
|
mod state;
|
99
crates/gitbutler-core/src/snapshots/reflog.rs
Normal file
99
crates/gitbutler-core/src/snapshots/reflog.rs
Normal 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(())
|
||||||
|
}
|
174
crates/gitbutler-core/src/snapshots/snapshot.rs
Normal file
174
crates/gitbutler-core/src/snapshots/snapshot.rs
Normal 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(())
|
||||||
|
}
|
81
crates/gitbutler-core/src/snapshots/state.rs
Normal file
81
crates/gitbutler-core/src/snapshots/state.rs
Normal 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)?)
|
||||||
|
}
|
@ -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
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -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;
|
||||||
|
@ -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,
|
||||||
|
37
crates/gitbutler-tauri/src/snapshots.rs
Normal file
37
crates/gitbutler-tauri/src/snapshots.rs
Normal 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(())
|
||||||
|
}
|
Loading…
Reference in New Issue
Block a user