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