implement undo stack snapshotting

This commit is contained in:
Kiril Videlov 2024-04-25 10:37:24 +02:00
parent cac2b6a80b
commit a2682e2535
7 changed files with 288 additions and 2 deletions

View File

@ -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;

View File

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

View 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)
}

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

@ -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;

View File

@ -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,

View 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)
}