diff --git a/crates/gitbutler-core/src/lib.rs b/crates/gitbutler-core/src/lib.rs index 4f86606f6..83161034e 100644 --- a/crates/gitbutler-core/src/lib.rs +++ b/crates/gitbutler-core/src/lib.rs @@ -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; diff --git a/crates/gitbutler-core/src/snapshots/mod.rs b/crates/gitbutler-core/src/snapshots/mod.rs new file mode 100644 index 000000000..bea2d953b --- /dev/null +++ b/crates/gitbutler-core/src/snapshots/mod.rs @@ -0,0 +1,3 @@ +pub mod reflog; +pub mod snapshot; +mod state; diff --git a/crates/gitbutler-core/src/snapshots/snapshot.rs b/crates/gitbutler-core/src/snapshots/snapshot.rs new file mode 100644 index 000000000..ba3e20966 --- /dev/null +++ b/crates/gitbutler-core/src/snapshots/snapshot.rs @@ -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 { + 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> { + 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 { + 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) +} diff --git a/crates/gitbutler-core/src/snapshots/state.rs b/crates/gitbutler-core/src/snapshots/state.rs new file mode 100644 index 000000000..90b83de51 --- /dev/null +++ b/crates/gitbutler-core/src/snapshots/state.rs @@ -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, +} + +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> { + 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 { + 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>(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)?) +} diff --git a/crates/gitbutler-tauri/src/lib.rs b/crates/gitbutler-tauri/src/lib.rs index f00172b11..5436cc6a4 100644 --- a/crates/gitbutler-tauri/src/lib.rs +++ b/crates/gitbutler-tauri/src/lib.rs @@ -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; diff --git a/crates/gitbutler-tauri/src/main.rs b/crates/gitbutler-tauri/src/main.rs index bffb0f094..df82705f4 100644 --- a/crates/gitbutler-tauri/src/main.rs +++ b/crates/gitbutler-tauri/src/main.rs @@ -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, diff --git a/crates/gitbutler-tauri/src/snapshots.rs b/crates/gitbutler-tauri/src/snapshots.rs new file mode 100644 index 000000000..b900d7339 --- /dev/null +++ b/crates/gitbutler-tauri/src/snapshots.rs @@ -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 { + let project = handle + .state::() + .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, Error> { + let project = handle + .state::() + .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 { + let project = handle + .state::() + .get(&project_id) + .context("failed to get project")?; + let snapshot_id = snapshot::restore(project, sha)?; + Ok(snapshot_id) +}