mirror of
https://github.com/gitbutlerapp/gitbutler.git
synced 2024-12-18 06:22:28 +03:00
implement undo stack snapshotting
This commit is contained in:
parent
cac2b6a80b
commit
a2682e2535
@ -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;
|
||||||
|
3
crates/gitbutler-core/src/snapshots/mod.rs
Normal file
3
crates/gitbutler-core/src/snapshots/mod.rs
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
pub mod reflog;
|
||||||
|
pub mod snapshot;
|
||||||
|
mod state;
|
145
crates/gitbutler-core/src/snapshots/snapshot.rs
Normal file
145
crates/gitbutler-core/src/snapshots/snapshot.rs
Normal file
@ -0,0 +1,145 @@
|
|||||||
|
use anyhow::Result;
|
||||||
|
use serde::Serialize;
|
||||||
|
|
||||||
|
use crate::{projects::Project, virtual_branches::VirtualBranchesHandle};
|
||||||
|
|
||||||
|
use super::state::OplogHandle;
|
||||||
|
|
||||||
|
#[derive(Debug, PartialEq, Clone, Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct SnapshotEntry {
|
||||||
|
pub sha: String,
|
||||||
|
pub label: String,
|
||||||
|
pub created_at: i64, // milliseconds since epoch
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn create(project: Project, label: String) -> Result<String> {
|
||||||
|
let repo_path = project.path.as_path();
|
||||||
|
let repo = git2::Repository::init(repo_path)?;
|
||||||
|
|
||||||
|
let oplog_state = OplogHandle::new(&project.gb_dir());
|
||||||
|
let oplog_head_commit = match oplog_state.get_oplog_head()? {
|
||||||
|
Some(head_sha) => repo.find_commit(git2::Oid::from_str(&head_sha)?)?,
|
||||||
|
// This is the first snapshot - use the default target as starting point
|
||||||
|
None => {
|
||||||
|
let vb_state = VirtualBranchesHandle::new(&project.gb_dir());
|
||||||
|
repo.find_commit(vb_state.get_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())?;
|
||||||
|
// TODO: update .git/logs/gitbutler/target
|
||||||
|
|
||||||
|
Ok(new_commit_oid.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
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();
|
||||||
|
|
||||||
|
// TODO: how do we know when to stop?
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn restore(project: Project, sha: String) -> Result<String> {
|
||||||
|
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 snapshot {}",
|
||||||
|
commit.message().unwrap_or(&sha)
|
||||||
|
);
|
||||||
|
let new_sha = create(project, label)?;
|
||||||
|
|
||||||
|
Ok(new_sha)
|
||||||
|
}
|
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)?)
|
||||||
|
}
|
@ -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,9 @@ 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::create_snapshot,
|
||||||
|
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,
|
||||||
|
52
crates/gitbutler-tauri/src/snapshots.rs
Normal file
52
crates/gitbutler-tauri/src/snapshots.rs
Normal file
@ -0,0 +1,52 @@
|
|||||||
|
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 create_snapshot(
|
||||||
|
handle: tauri::AppHandle,
|
||||||
|
project_id: ProjectId,
|
||||||
|
label: String,
|
||||||
|
) -> Result<String, Error> {
|
||||||
|
let project = handle
|
||||||
|
.state::<projects::Controller>()
|
||||||
|
.get(&project_id)
|
||||||
|
.context("failed to get project")?;
|
||||||
|
let snapshot_id = snapshot::create(project, label)?;
|
||||||
|
Ok(snapshot_id)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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<String, Error> {
|
||||||
|
let project = handle
|
||||||
|
.state::<projects::Controller>()
|
||||||
|
.get(&project_id)
|
||||||
|
.context("failed to get project")?;
|
||||||
|
let snapshot_id = snapshot::restore(project, sha)?;
|
||||||
|
Ok(snapshot_id)
|
||||||
|
}
|
Loading…
Reference in New Issue
Block a user