mirror of
https://github.com/gitbutlerapp/gitbutler.git
synced 2024-11-28 22:03:30 +03:00
commit
f9ca7374e8
4
Cargo.lock
generated
4
Cargo.lock
generated
@ -2723,9 +2723,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "gix-ref"
|
||||
version = "0.44.0"
|
||||
version = "0.44.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0b36752b448647acd59c9668fdd830b16d07db1e6d9c3b3af105c1605a6e23d9"
|
||||
checksum = "3394a2997e5bc6b22ebc1e1a87b41eeefbcfcff3dbfa7c4bd73cb0ac8f1f3e2e"
|
||||
dependencies = [
|
||||
"gix-actor",
|
||||
"gix-date",
|
||||
|
@ -1,5 +1,5 @@
|
||||
use anyhow::Result;
|
||||
use gitbutler_core::{ops::oplog::Oplog, projects::Project};
|
||||
use gitbutler_core::projects::Project;
|
||||
|
||||
use clap::{arg, Command};
|
||||
#[cfg(not(windows))]
|
||||
@ -51,10 +51,10 @@ fn list_snapshots(repo_dir: &str) -> Result<()> {
|
||||
let project = project_from_path(repo_dir);
|
||||
let snapshots = project.list_snapshots(100, None)?;
|
||||
for snapshot in snapshots {
|
||||
let ts = chrono::DateTime::from_timestamp(snapshot.created_at / 1000, 0);
|
||||
let ts = chrono::DateTime::from_timestamp(snapshot.created_at.seconds(), 0);
|
||||
let details = snapshot.details;
|
||||
if let (Some(ts), Some(details)) = (ts, details) {
|
||||
println!("{} {} {}", ts, snapshot.id, details.operation);
|
||||
println!("{} {} {}", ts, snapshot.commit_id, details.operation);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
@ -62,7 +62,7 @@ fn list_snapshots(repo_dir: &str) -> Result<()> {
|
||||
|
||||
fn restore_snapshot(repo_dir: &str, snapshot_id: &str) -> Result<()> {
|
||||
let project = project_from_path(repo_dir);
|
||||
project.restore_snapshot(snapshot_id.to_owned())?;
|
||||
project.restore_snapshot(snapshot_id.parse()?)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
@ -34,7 +34,7 @@ regex = "1.10"
|
||||
reqwest = { version = "0.12.4", features = ["json"] }
|
||||
resolve-path = "0.1.0"
|
||||
rusqlite.workspace = true
|
||||
serde.workspace = true
|
||||
serde = { workspace = true, features = ["std"]}
|
||||
serde_json = { version = "1.0", features = [ "std", "arbitrary_precision" ] }
|
||||
sha2 = "0.10.8"
|
||||
ssh-key = { version = "0.6.6", features = [ "alloc", "ed25519" ] }
|
||||
|
@ -1,3 +1,5 @@
|
||||
use std::fs::File;
|
||||
use std::io::Read;
|
||||
use std::{
|
||||
io::Write,
|
||||
path::{Path, PathBuf},
|
||||
@ -9,6 +11,7 @@ use gix::{
|
||||
dir::walk::EmissionMode,
|
||||
tempfile::{create_dir::Retries, AutoRemove, ContainingDirectory},
|
||||
};
|
||||
use serde::de::DeserializeOwned;
|
||||
use walkdir::WalkDir;
|
||||
|
||||
// Returns an ordered list of relative paths for files inside a directory recursively.
|
||||
@ -97,3 +100,23 @@ fn persist_tempfile(
|
||||
Err(err) => Err(err.error),
|
||||
}
|
||||
}
|
||||
|
||||
/// Reads and parses the state file.
|
||||
///
|
||||
/// If the file does not exist, it will be created.
|
||||
pub(crate) fn read_toml_file_or_default<T: DeserializeOwned + Default>(
|
||||
path: &Path,
|
||||
) -> Result<T, crate::reader::Error> {
|
||||
let mut file = match File::open(path) {
|
||||
Ok(f) => f,
|
||||
Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(T::default()),
|
||||
Err(err) => return Err(err.into()),
|
||||
};
|
||||
let mut contents = String::new();
|
||||
file.read_to_string(&mut contents)?;
|
||||
let value: T = toml::from_str(&contents).map_err(|err| crate::reader::Error::ParseError {
|
||||
path: path.to_owned(),
|
||||
source: err,
|
||||
})?;
|
||||
Ok(value)
|
||||
}
|
||||
|
@ -56,4 +56,56 @@ pub mod serde {
|
||||
{
|
||||
format!("{v:x}").serialize(s)
|
||||
}
|
||||
|
||||
pub fn as_time_seconds_from_unix_epoch<S>(v: &git2::Time, s: S) -> Result<S::Ok, S::Error>
|
||||
where
|
||||
S: serde::Serializer,
|
||||
{
|
||||
v.seconds().serialize(s)
|
||||
}
|
||||
|
||||
pub mod oid_opt {
|
||||
use crate::git;
|
||||
use serde::{Deserialize, Deserializer, Serialize};
|
||||
|
||||
pub fn serialize<S>(v: &Option<git::Oid>, s: S) -> Result<S::Ok, S::Error>
|
||||
where
|
||||
S: serde::Serializer,
|
||||
{
|
||||
v.as_ref().map(|v| v.to_string()).serialize(s)
|
||||
}
|
||||
|
||||
pub fn deserialize<'de, D>(d: D) -> Result<Option<git::Oid>, D::Error>
|
||||
where
|
||||
D: Deserializer<'de>,
|
||||
{
|
||||
let hex = <Option<String> as Deserialize>::deserialize(d)?;
|
||||
hex.map(|v| {
|
||||
v.parse()
|
||||
.map_err(|err: git2::Error| serde::de::Error::custom(err.to_string()))
|
||||
})
|
||||
.transpose()
|
||||
}
|
||||
}
|
||||
|
||||
pub mod oid {
|
||||
use crate::git;
|
||||
use serde::{Deserialize, Deserializer, Serialize};
|
||||
|
||||
pub fn serialize<S>(v: &git::Oid, s: S) -> Result<S::Ok, S::Error>
|
||||
where
|
||||
S: serde::Serializer,
|
||||
{
|
||||
v.to_string().serialize(s)
|
||||
}
|
||||
|
||||
pub fn deserialize<'de, D>(d: D) -> Result<git::Oid, D::Error>
|
||||
where
|
||||
D: Deserializer<'de>,
|
||||
{
|
||||
let hex = String::deserialize(d)?;
|
||||
hex.parse()
|
||||
.map_err(|err: git2::Error| serde::de::Error::custom(err.to_string()))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -3,12 +3,13 @@ use anyhow::Result;
|
||||
use itertools::Itertools;
|
||||
use serde::Deserialize;
|
||||
use std::fmt;
|
||||
use std::fmt::Display;
|
||||
use std::fmt::Formatter;
|
||||
use std::fmt::{Debug, Display};
|
||||
use std::path::PathBuf;
|
||||
use std::str::FromStr;
|
||||
use strum::EnumString;
|
||||
|
||||
use crate::git;
|
||||
use serde::Serialize;
|
||||
|
||||
/// A snapshot of the repository and virtual branches state that GitButler can restore to.
|
||||
@ -16,17 +17,19 @@ use serde::Serialize;
|
||||
#[derive(Debug, PartialEq, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Snapshot {
|
||||
/// The sha of the commit that represents the snapshot
|
||||
pub id: String,
|
||||
/// Snapshot creation time in epoch seconds
|
||||
pub created_at: i64,
|
||||
/// The id of the commit that represents the snapshot
|
||||
#[serde(rename = "id", with = "crate::serde::oid")]
|
||||
pub commit_id: git::Oid,
|
||||
/// Snapshot creation time in seconds from Unix epoch seconds, based on a commit as `commit_id`.
|
||||
#[serde(serialize_with = "crate::serde::as_time_seconds_from_unix_epoch")]
|
||||
pub created_at: git2::Time,
|
||||
/// The number of working directory lines added in the snapshot
|
||||
pub lines_added: usize,
|
||||
/// The number of working directory lines removed in the snapshot
|
||||
pub lines_removed: usize,
|
||||
/// The list of working directory files that were changed in the snapshot
|
||||
pub files_changed: Vec<PathBuf>,
|
||||
/// Snapshot details as persisted in the commit message
|
||||
/// Snapshot details as persisted in the commit message, or `None` if the details couldn't be parsed.
|
||||
pub details: Option<SnapshotDetails>,
|
||||
}
|
||||
|
||||
@ -39,8 +42,8 @@ pub struct SnapshotDetails {
|
||||
/// The version of the snapshot format
|
||||
pub version: Version,
|
||||
/// The type of operation that was performed just before the snapshot was created
|
||||
pub operation: OperationType,
|
||||
/// The title / lablel of the snapshot
|
||||
pub operation: OperationKind,
|
||||
/// The title / label of the snapshot
|
||||
pub title: String,
|
||||
/// Additional text describing the snapshot
|
||||
pub body: Option<String>,
|
||||
@ -49,7 +52,7 @@ pub struct SnapshotDetails {
|
||||
}
|
||||
|
||||
impl SnapshotDetails {
|
||||
pub fn new(operation: OperationType) -> Self {
|
||||
pub fn new(operation: OperationKind) -> Self {
|
||||
let title = operation.to_string();
|
||||
SnapshotDetails {
|
||||
version: Default::default(),
|
||||
@ -70,39 +73,31 @@ impl FromStr for SnapshotDetails {
|
||||
|
||||
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||
let message_lines: Vec<&str> = s.lines().collect();
|
||||
let mut split: Vec<Vec<&str>> = message_lines
|
||||
.split(|line| line.is_empty())
|
||||
.map(|s| s.to_vec())
|
||||
.collect();
|
||||
let mut split: Vec<&[&str]> = message_lines.split(|line| line.is_empty()).collect();
|
||||
let title = split.remove(0).join("\n");
|
||||
let mut trailers: Vec<Trailer> = split
|
||||
.pop()
|
||||
.ok_or(anyhow!("No trailers found on snapshot commit message"))?
|
||||
.iter()
|
||||
.map(|s| Trailer::from_str(s))
|
||||
.filter_map(Result::ok)
|
||||
.filter_map(|s| Trailer::from_str(s).ok())
|
||||
.collect();
|
||||
let body = split.iter().map(|v| v.join("\n")).join("\n\n");
|
||||
let body = if body.is_empty() { None } else { Some(body) };
|
||||
|
||||
let version = Version::from_str(
|
||||
&trailers
|
||||
.iter()
|
||||
.find(|t| t.key == "Version")
|
||||
.cloned()
|
||||
.ok_or(anyhow!("No version found on snapshot commit message"))?
|
||||
.value,
|
||||
)?;
|
||||
let version = trailers
|
||||
.iter()
|
||||
.find(|t| t.key == "Version")
|
||||
.ok_or(anyhow!("No version found on snapshot commit message"))?
|
||||
.value
|
||||
.parse()?;
|
||||
|
||||
let operation = OperationType::from_str(
|
||||
&trailers
|
||||
.iter()
|
||||
.find(|t| t.key == "Operation")
|
||||
.cloned()
|
||||
.ok_or(anyhow!("No operation found on snapshot commit message"))?
|
||||
.value,
|
||||
)
|
||||
.unwrap_or(Default::default());
|
||||
let operation = trailers
|
||||
.iter()
|
||||
.find(|t| t.key == "Operation")
|
||||
.ok_or(anyhow!("No operation found on snapshot commit message"))?
|
||||
.value
|
||||
.parse()
|
||||
.unwrap_or_default();
|
||||
|
||||
// remove the version and operation attributes from the trailers since they have dedicated fields
|
||||
trailers.retain(|t| t.key != "Version" && t.key != "Operation");
|
||||
@ -119,11 +114,9 @@ impl FromStr for SnapshotDetails {
|
||||
|
||||
impl Display for SnapshotDetails {
|
||||
fn fmt(&self, f: &mut Formatter) -> fmt::Result {
|
||||
writeln!(f, "{}", self.title)?;
|
||||
writeln!(f)?;
|
||||
writeln!(f, "{}\n", self.title)?;
|
||||
if let Some(body) = &self.body {
|
||||
writeln!(f, "{}", body)?;
|
||||
writeln!(f)?;
|
||||
writeln!(f, "{}\n", body)?;
|
||||
}
|
||||
writeln!(f, "Version: {}", self.version)?;
|
||||
writeln!(f, "Operation: {}", self.operation)?;
|
||||
@ -134,8 +127,8 @@ impl Display for SnapshotDetails {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, Clone, Serialize, Deserialize, EnumString, Default)]
|
||||
pub enum OperationType {
|
||||
#[derive(Debug, PartialEq, Clone, Copy, Serialize, Deserialize, EnumString, Default)]
|
||||
pub enum OperationKind {
|
||||
CreateCommit,
|
||||
CreateBranch,
|
||||
SetBaseBranch,
|
||||
@ -168,9 +161,15 @@ pub enum OperationType {
|
||||
Unknown,
|
||||
}
|
||||
|
||||
impl fmt::Display for OperationType {
|
||||
impl From<OperationKind> for SnapshotDetails {
|
||||
fn from(value: OperationKind) -> Self {
|
||||
SnapshotDetails::new(value)
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for OperationKind {
|
||||
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
|
||||
write!(f, "{:?}", self)
|
||||
Debug::fmt(self, f)
|
||||
}
|
||||
}
|
||||
|
||||
@ -182,9 +181,9 @@ impl Default for Version {
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for Version {
|
||||
impl Display for Version {
|
||||
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
|
||||
write!(f, "{}", self.0)
|
||||
Display::fmt(&self.0, f)
|
||||
}
|
||||
}
|
||||
|
||||
@ -196,7 +195,7 @@ impl FromStr for Version {
|
||||
}
|
||||
}
|
||||
|
||||
/// Represents a key value pair stored in a snapshot.
|
||||
/// Represents a key value pair stored in a snapshot, like `key: value\n`
|
||||
/// Using the git trailer format (https://git-scm.com/docs/git-interpret-trailers)
|
||||
#[derive(Debug, PartialEq, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
@ -219,7 +218,7 @@ impl FromStr for Trailer {
|
||||
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||
let parts: Vec<&str> = s.splitn(2, ':').collect();
|
||||
if parts.len() != 2 {
|
||||
return Err(anyhow!("Invalid trailer format"));
|
||||
return Err(anyhow!("Invalid trailer format, expected `key: value`"));
|
||||
}
|
||||
let unescaped_value = parts[1].trim().replace("\\n", "\n");
|
||||
Ok(Self {
|
||||
@ -228,128 +227,3 @@ impl FromStr for Trailer {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_trailer_display() {
|
||||
let trailer = Trailer {
|
||||
key: "foo".to_string(),
|
||||
value: "bar".to_string(),
|
||||
};
|
||||
assert_eq!(format!("{}", trailer), "foo: bar");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_trailer_from_str() {
|
||||
let s = "foo: bar";
|
||||
let trailer = Trailer::from_str(s).unwrap();
|
||||
assert_eq!(trailer.key, "foo");
|
||||
assert_eq!(trailer.value, "bar");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_trailer_from_str_invalid() {
|
||||
let s = "foobar";
|
||||
let result = Trailer::from_str(s);
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_version_from_trailer() {
|
||||
let s = "Version: 1";
|
||||
let trailer = Trailer::from_str(s).unwrap();
|
||||
let version = Version::from_str(&trailer.value).unwrap();
|
||||
assert_eq!(version.0, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_version_invalid() {
|
||||
let s = "Version: -1";
|
||||
let trailer = Trailer::from_str(s).unwrap();
|
||||
let version = Version::from_str(&trailer.value);
|
||||
assert!(version.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_operation_type_from_trailer() {
|
||||
let s = "Operation: CreateCommit";
|
||||
let trailer = Trailer::from_str(s).unwrap();
|
||||
let operation = OperationType::from_str(&trailer.value).unwrap();
|
||||
assert_eq!(operation, OperationType::CreateCommit);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_operation_unknown() {
|
||||
let commit_message = "Create a new snapshot\n\nBody text 1\nBody text2\n\nBody text 3\n\nVersion: 1\nOperation: Asdf\nFoo: Bar\n";
|
||||
let details = SnapshotDetails::from_str(commit_message).unwrap();
|
||||
assert_eq!(details.version.0, 1);
|
||||
assert_eq!(details.operation, OperationType::Unknown);
|
||||
assert_eq!(details.title, "Create a new snapshot");
|
||||
assert_eq!(
|
||||
details.body,
|
||||
Some("Body text 1\nBody text2\n\nBody text 3".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
details.trailers,
|
||||
vec![Trailer {
|
||||
key: "Foo".to_string(),
|
||||
value: "Bar".to_string(),
|
||||
}]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_new_snapshot() {
|
||||
let commit_sha = "1234567890".to_string();
|
||||
let commit_message =
|
||||
"Create a new snapshot\n\nBody text 1\nBody text2\n\nBody text 3\n\nVersion: 1\nOperation: CreateCommit\nFoo: Bar\n".to_string();
|
||||
let created_at = 1234567890;
|
||||
let details = SnapshotDetails::from_str(&commit_message.clone()).unwrap();
|
||||
let snapshot = Snapshot {
|
||||
id: commit_sha.clone(),
|
||||
created_at,
|
||||
lines_added: 1,
|
||||
lines_removed: 1,
|
||||
files_changed: vec![PathBuf::from("foo.txt")],
|
||||
details: Some(details),
|
||||
};
|
||||
assert_eq!(snapshot.id, commit_sha);
|
||||
assert_eq!(snapshot.created_at, created_at);
|
||||
let details = snapshot.details.unwrap();
|
||||
assert_eq!(details.version.0, 1);
|
||||
assert_eq!(details.operation, OperationType::CreateCommit);
|
||||
assert_eq!(details.title, "Create a new snapshot");
|
||||
assert_eq!(
|
||||
details.body,
|
||||
Some("Body text 1\nBody text2\n\nBody text 3".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
details.trailers,
|
||||
vec![Trailer {
|
||||
key: "Foo".to_string(),
|
||||
value: "Bar".to_string(),
|
||||
}]
|
||||
);
|
||||
assert_eq!(details.to_string(), commit_message);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_create_snapshot_containing_trailer_with_newline() {
|
||||
let snapshot_details = SnapshotDetails {
|
||||
version: Version(1),
|
||||
operation: OperationType::CreateCommit,
|
||||
title: "Create a new snapshot".to_string(),
|
||||
body: None,
|
||||
trailers: vec![Trailer {
|
||||
key: "Message".to_string(),
|
||||
value: "Header\n\nBody".to_string(),
|
||||
}],
|
||||
};
|
||||
let serialized = snapshot_details.to_string();
|
||||
let deserialized = SnapshotDetails::from_str(&serialized).unwrap();
|
||||
assert_eq!(deserialized, snapshot_details)
|
||||
}
|
||||
}
|
||||
|
@ -1,7 +1,8 @@
|
||||
pub mod entry;
|
||||
pub mod oplog;
|
||||
mod oplog;
|
||||
mod reflog;
|
||||
pub mod snapshot;
|
||||
mod snapshot;
|
||||
mod state;
|
||||
|
||||
/// The name of the file holding our state, useful for watching for changes.
|
||||
pub const OPLOG_FILE_NAME: &str = "operations-log.toml";
|
||||
|
@ -1,5 +1,5 @@
|
||||
use anyhow::anyhow;
|
||||
use git2::{FileMode, Oid};
|
||||
use anyhow::{anyhow, bail, Context};
|
||||
use git2::FileMode;
|
||||
use itertools::Itertools;
|
||||
use std::collections::HashMap;
|
||||
use std::str::FromStr;
|
||||
@ -7,24 +7,32 @@ use std::time::Duration;
|
||||
use std::{fs, path::PathBuf};
|
||||
|
||||
use anyhow::Result;
|
||||
use tracing::instrument;
|
||||
|
||||
use crate::git::diff::FileDiff;
|
||||
use crate::virtual_branches::integration::{
|
||||
GITBUTLER_INTEGRATION_COMMIT_AUTHOR_EMAIL, GITBUTLER_INTEGRATION_COMMIT_AUTHOR_NAME,
|
||||
};
|
||||
use crate::virtual_branches::Branch;
|
||||
use crate::{git::diff::hunks_by_filepath, projects::Project};
|
||||
use crate::{git, git::diff::hunks_by_filepath, projects::Project};
|
||||
|
||||
use super::{
|
||||
entry::{OperationType, Snapshot, SnapshotDetails, Trailer},
|
||||
entry::{OperationKind, Snapshot, SnapshotDetails, Trailer},
|
||||
reflog::set_reference_to_oplog,
|
||||
state::OplogHandle,
|
||||
};
|
||||
|
||||
const SNAPSHOT_FILE_LIMIT_BYTES: u64 = 32 * 1024 * 1024;
|
||||
|
||||
/// The Oplog trait allows for crating snapshots of the current state of the project as well as restoring to a previous snapshot.
|
||||
/// Snapshots include the state of the working directory as well as all additional GitButler state (e.g virtual branches, conflict state).
|
||||
/// The Oplog allows for crating snapshots of the current state of the project as well as restoring to a previous snapshot.
|
||||
/// Snapshots include the state of the working directory as well as all additional GitButler state (e.g. virtual branches, conflict state).
|
||||
/// The data is stored as git trees in the following shape:
|
||||
///
|
||||
/// ```text
|
||||
/// .
|
||||
/// ├── workdir/
|
||||
/// ├── conflicts/…
|
||||
/// ├── index/
|
||||
/// ├── target_tree/…
|
||||
/// ├── virtual_branches
|
||||
/// │ └── [branch-id]
|
||||
/// │ ├── commit-message.txt
|
||||
@ -32,81 +40,30 @@ const SNAPSHOT_FILE_LIMIT_BYTES: u64 = 32 * 1024 * 1024;
|
||||
/// │ └── [branch-id]
|
||||
/// │ ├── commit-message.txt
|
||||
/// │ └── tree (subtree)
|
||||
/// ├── workdir/…
|
||||
/// └── virtual_branches.toml
|
||||
pub trait Oplog {
|
||||
/// ```
|
||||
impl Project {
|
||||
/// Prepares a snapshot of the current state of the working directory as well as GitButler data.
|
||||
/// Returns a tree sha of the snapshot. The snapshot is not discoverable until it is comitted with `commit_snapshot`
|
||||
/// If there are files that are untracked and larger than SNAPSHOT_FILE_LIMIT_BYTES, they are excluded from snapshot creation and restoring.
|
||||
fn prepare_snapshot(&self) -> Result<String>;
|
||||
/// Commits the snapshot tree that is created with the `prepare_snapshot` method.
|
||||
/// Committing it makes the snapshot discoverable in `list_snapshots` as well as restorable with `restore_snapshot`.
|
||||
/// Returns the sha of the created snapshot commit or None if snapshots are disabled.
|
||||
fn commit_snapshot(
|
||||
&self,
|
||||
snapshot_tree_sha: String,
|
||||
details: SnapshotDetails,
|
||||
) -> Result<Option<String>>;
|
||||
/// Creates a snapshot of the current state of the working directory as well as GitButler data.
|
||||
/// This is a convinience method that combines `prepare_snapshot` and `commit_snapshot`.
|
||||
fn create_snapshot(&self, details: SnapshotDetails) -> Result<Option<String>>;
|
||||
/// 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/operations-log.toml`.
|
||||
///
|
||||
/// If there are no snapshots, an empty list is returned.
|
||||
fn list_snapshots(&self, limit: usize, sha: Option<String>) -> Result<Vec<Snapshot>>;
|
||||
/// 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.
|
||||
///
|
||||
/// This will restore the following:
|
||||
/// - The state of the working directory is checked out from the subtree `workdir` in the snapshot.
|
||||
/// - The state of virtual branches is restored from the blob `virtual_branches.toml` in the snapshot.
|
||||
/// - The state of conflicts (.git/base_merge_parent and .git/conflicts) is restored from the subtree `conflicts` in the snapshot (if not present, existing files are deleted).
|
||||
///
|
||||
/// If there are files that are untracked and larger than SNAPSHOT_FILE_LIMIT_BYTES, they are excluded from snapshot creation and restoring.
|
||||
/// Returns the sha of the created revert snapshot commit or None if snapshots are disabled.
|
||||
fn restore_snapshot(&self, sha: String) -> Result<Option<String>>;
|
||||
/// Determines if a new snapshot should be created due to file changes being created since the last snapshot.
|
||||
/// The needs for the automatic snapshotting are:
|
||||
/// - It needs to facilitate backup of work in progress code
|
||||
/// - The snapshots should not be too frequent or small - both for UX and performance reasons
|
||||
/// - Checking if an automatic snapshot is needed should be fast and efficient since it is called on filesystem events
|
||||
///
|
||||
/// This implementation works as follows:
|
||||
/// - If it's been more than 5 minutes since the last snapshot,
|
||||
/// check the sum of added and removed lines since the last snapshot, otherwise return false.
|
||||
/// - If the sum of added and removed lines is greater than a configured threshold, return true, otherwise return false.
|
||||
fn should_auto_snapshot(&self) -> Result<bool>;
|
||||
/// Returns the diff of the snapshot and it's parent. It only includes the workdir changes.
|
||||
///
|
||||
/// This is useful to show what has changed in this particular snapshot
|
||||
fn snapshot_diff(&self, sha: String) -> Result<HashMap<PathBuf, FileDiff>>;
|
||||
/// Gets the sha of the last snapshot commit if present.
|
||||
fn oplog_head(&self) -> Result<Option<String>>;
|
||||
}
|
||||
|
||||
impl Oplog for Project {
|
||||
fn prepare_snapshot(&self) -> Result<String> {
|
||||
let repo_path = self.path.as_path();
|
||||
let repo = git2::Repository::init(repo_path)?;
|
||||
/// Returns a tree hash of the snapshot. The snapshot is not discoverable until it is committed with [`commit_snapshot`](Self::commit_snapshot())
|
||||
/// If there are files that are untracked and larger than `SNAPSHOT_FILE_LIMIT_BYTES`, they are excluded from snapshot creation and restoring.
|
||||
pub(crate) fn prepare_snapshot(&self) -> Result<git::Oid> {
|
||||
let worktree_dir = self.path.as_path();
|
||||
let repo = git2::Repository::open(worktree_dir)?;
|
||||
|
||||
let vb_state = self.virtual_branches();
|
||||
|
||||
// grab the target tree sha
|
||||
let default_target_sha = vb_state.get_default_target()?.sha;
|
||||
let default_target = repo.find_commit(default_target_sha.into())?;
|
||||
let target_tree_oid = default_target.tree_id();
|
||||
// grab the target commit
|
||||
let default_target_commit = repo.find_commit(vb_state.get_default_target()?.sha.into())?;
|
||||
let target_tree_id = default_target_commit.tree_id();
|
||||
|
||||
// Create a blob out of `.git/gitbutler/virtual_branches.toml`
|
||||
let vb_path = repo_path
|
||||
.join(".git")
|
||||
.join("gitbutler")
|
||||
.join("virtual_branches.toml");
|
||||
let vb_path = repo.path().join("gitbutler").join("virtual_branches.toml");
|
||||
let vb_content = fs::read(vb_path)?;
|
||||
let vb_blob = repo.blob(&vb_content)?;
|
||||
let vb_blob_id = repo.blob(&vb_content)?;
|
||||
|
||||
// Create a tree out of the conflicts state if present
|
||||
let conflicts_tree = write_conflicts_tree(repo_path, &repo)?;
|
||||
let conflicts_tree_id = write_conflicts_tree(worktree_dir, &repo)?;
|
||||
|
||||
// write out the index as a tree to store
|
||||
let mut index = repo.index()?;
|
||||
@ -115,17 +72,17 @@ impl Oplog for Project {
|
||||
// start building our snapshot tree
|
||||
let mut tree_builder = repo.treebuilder(None)?;
|
||||
tree_builder.insert("index", index_tree_oid, FileMode::Tree.into())?;
|
||||
tree_builder.insert("target_tree", target_tree_oid, FileMode::Tree.into())?;
|
||||
tree_builder.insert("conflicts", conflicts_tree, FileMode::Tree.into())?;
|
||||
tree_builder.insert("virtual_branches.toml", vb_blob, FileMode::Blob.into())?;
|
||||
tree_builder.insert("target_tree", target_tree_id, FileMode::Tree.into())?;
|
||||
tree_builder.insert("conflicts", conflicts_tree_id.into(), FileMode::Tree.into())?;
|
||||
tree_builder.insert("virtual_branches.toml", vb_blob_id, FileMode::Blob.into())?;
|
||||
|
||||
// go through all virtual branches and create a subtree for each with the tree and any commits encoded
|
||||
let mut branches_tree_builder = repo.treebuilder(None)?;
|
||||
let mut head_trees = Vec::new();
|
||||
let mut head_tree_ids = Vec::new();
|
||||
|
||||
for branch in vb_state.list_branches()? {
|
||||
if branch.applied {
|
||||
head_trees.push(branch.tree);
|
||||
head_tree_ids.push(branch.tree);
|
||||
}
|
||||
|
||||
// commits in virtual branches (tree and commit data)
|
||||
@ -133,10 +90,10 @@ impl Oplog for Project {
|
||||
let mut branch_tree_builder = repo.treebuilder(None)?;
|
||||
branch_tree_builder.insert("tree", branch.tree.into(), FileMode::Tree.into())?;
|
||||
|
||||
// lets get all the commits between the branch head and the target
|
||||
// let's get all the commits between the branch head and the target
|
||||
let mut revwalk = repo.revwalk()?;
|
||||
revwalk.push(branch.head.into())?;
|
||||
revwalk.hide(default_target.id())?;
|
||||
revwalk.hide(default_target_commit.id())?;
|
||||
|
||||
let mut commits_tree_builder = repo.treebuilder(None)?;
|
||||
for commit_id in revwalk {
|
||||
@ -145,19 +102,11 @@ impl Oplog for Project {
|
||||
let commit_tree = commit.tree()?;
|
||||
|
||||
let mut commit_tree_builder = repo.treebuilder(None)?;
|
||||
|
||||
// get the raw commit data
|
||||
let commit_header = commit.raw_header_bytes();
|
||||
let commit_message = commit.message_raw_bytes();
|
||||
let commit_data = [commit_header, b"\n", commit_message].concat();
|
||||
|
||||
// convert that data into a blob
|
||||
let commit_data_blob = repo.blob(&commit_data)?;
|
||||
commit_tree_builder.insert("commit", commit_data_blob, FileMode::Blob.into())?;
|
||||
|
||||
let commit_data_blob_id = repo.blob(&serialize_commit(&commit))?;
|
||||
commit_tree_builder.insert("tree", commit_tree.id(), FileMode::Tree.into())?;
|
||||
|
||||
commit_tree_builder.insert("commit", commit_data_blob_id, FileMode::Blob.into())?;
|
||||
let commit_tree_id = commit_tree_builder.write()?;
|
||||
|
||||
commits_tree_builder.insert(
|
||||
&commit_id.to_string(),
|
||||
commit_tree_id,
|
||||
@ -178,35 +127,30 @@ impl Oplog for Project {
|
||||
|
||||
// also add the gitbutler/integration commit to the branches tree
|
||||
let head = repo.head()?;
|
||||
if head.is_branch() && head.name().unwrap() == "refs/heads/gitbutler/integration" {
|
||||
let commit = head.peel_to_commit()?;
|
||||
let commit_tree = commit.tree()?;
|
||||
if head.is_branch() && head.name() == Some("refs/heads/gitbutler/integration") {
|
||||
let head_commit = head.peel_to_commit()?;
|
||||
let head_tree = head_commit.tree()?;
|
||||
|
||||
let mut commit_tree_builder = repo.treebuilder(None)?;
|
||||
|
||||
// get the raw commit data
|
||||
let commit_header = commit.raw_header_bytes();
|
||||
let commit_message = commit.message_raw_bytes();
|
||||
let commit_data = [commit_header, b"\n", commit_message].concat();
|
||||
let mut head_commit_tree_builder = repo.treebuilder(None)?;
|
||||
|
||||
// convert that data into a blob
|
||||
let commit_data_blob = repo.blob(&commit_data)?;
|
||||
commit_tree_builder.insert("commit", commit_data_blob, FileMode::Blob.into())?;
|
||||
commit_tree_builder.insert("tree", commit_tree.id(), FileMode::Tree.into())?;
|
||||
let commit_data_blob = repo.blob(&serialize_commit(&head_commit))?;
|
||||
head_commit_tree_builder.insert("commit", commit_data_blob, FileMode::Blob.into())?;
|
||||
head_commit_tree_builder.insert("tree", head_tree.id(), FileMode::Tree.into())?;
|
||||
|
||||
let commit_tree_id = commit_tree_builder.write()?;
|
||||
let head_commit_tree_id = head_commit_tree_builder.write()?;
|
||||
|
||||
// gotta make a subtree to match
|
||||
// have to make a subtree to match
|
||||
let mut commits_tree_builder = repo.treebuilder(None)?;
|
||||
commits_tree_builder.insert(
|
||||
commit.id().to_string(),
|
||||
commit_tree_id,
|
||||
head_commit.id().to_string(),
|
||||
head_commit_tree_id,
|
||||
FileMode::Tree.into(),
|
||||
)?;
|
||||
let commits_tree_id = commits_tree_builder.write()?;
|
||||
|
||||
let mut branch_tree_builder = repo.treebuilder(None)?;
|
||||
branch_tree_builder.insert("tree", commit_tree.id(), FileMode::Tree.into())?;
|
||||
branch_tree_builder.insert("tree", head_tree.id(), FileMode::Tree.into())?;
|
||||
branch_tree_builder.insert("commits", commits_tree_id, FileMode::Tree.into())?;
|
||||
let branch_tree_id = branch_tree_builder.write()?;
|
||||
|
||||
@ -218,116 +162,138 @@ impl Oplog for Project {
|
||||
|
||||
// merge all the branch trees together, this should be our worktree
|
||||
// TODO: when we implement sub-hunk splitting, this merge logic will need to incorporate that
|
||||
if head_trees.is_empty() {
|
||||
if head_tree_ids.is_empty() {
|
||||
// if there are no applied branches, then it's just the target tree
|
||||
tree_builder.insert("workdir", target_tree_oid, FileMode::Tree.into())?;
|
||||
} else if head_trees.len() == 1 {
|
||||
tree_builder.insert("workdir", target_tree_id, FileMode::Tree.into())?;
|
||||
} else if head_tree_ids.len() == 1 {
|
||||
// if there is just one applied branch, then it's just that branch tree
|
||||
tree_builder.insert("workdir", head_trees[0].into(), FileMode::Tree.into())?;
|
||||
tree_builder.insert("workdir", head_tree_ids[0].into(), FileMode::Tree.into())?;
|
||||
} else {
|
||||
// otherwise merge one branch tree at a time with target_tree_oid as the base
|
||||
let mut workdir_tree_oid = target_tree_oid;
|
||||
let base_tree = repo.find_tree(target_tree_oid)?;
|
||||
let mut workdir_tree_id = target_tree_id;
|
||||
let base_tree = repo.find_tree(target_tree_id)?;
|
||||
let mut current_ours = base_tree.clone();
|
||||
|
||||
let head_trees_iter = head_trees.iter();
|
||||
// iterate through all head trees
|
||||
for head_tree in head_trees_iter {
|
||||
let current_theirs = repo.find_tree(git2::Oid::from(*head_tree))?;
|
||||
// TODO: This needs a test. Right now only the last merged index is actually used.
|
||||
for head_tree_id in head_tree_ids {
|
||||
let current_theirs = repo.find_tree(head_tree_id.into())?;
|
||||
let mut workdir_temp_index =
|
||||
repo.merge_trees(&base_tree, ¤t_ours, ¤t_theirs, None)?;
|
||||
workdir_tree_oid = workdir_temp_index.write_tree_to(&repo)?;
|
||||
workdir_tree_id = workdir_temp_index.write_tree_to(&repo)?;
|
||||
current_ours = current_theirs;
|
||||
}
|
||||
tree_builder.insert("workdir", workdir_tree_oid, FileMode::Tree.into())?;
|
||||
tree_builder.insert("workdir", workdir_tree_id, FileMode::Tree.into())?;
|
||||
}
|
||||
|
||||
// ok, write out the final oplog tree
|
||||
let tree_id = tree_builder.write()?;
|
||||
Ok(tree_id.to_string())
|
||||
Ok(tree_id.into())
|
||||
}
|
||||
|
||||
fn commit_snapshot(&self, tree_id: String, details: SnapshotDetails) -> Result<Option<String>> {
|
||||
let repo_path = self.path.as_path();
|
||||
let repo = git2::Repository::init(repo_path)?;
|
||||
|
||||
let tree = repo.find_tree(Oid::from_str(&tree_id)?)?;
|
||||
/// Commits the snapshot tree that is created with the [`prepare_snapshot`](Self::prepare_snapshot) method,
|
||||
/// which yielded the `snapshot_tree_id` for the entire snapshot state.
|
||||
/// Use `details` to provide metadata about the snapshot.
|
||||
///
|
||||
/// Committing it makes the snapshot discoverable in [`list_snapshots`](Self::list_snapshots) as well as
|
||||
/// restorable with [`restore_snapshot`](Self::restore_snapshot).
|
||||
///
|
||||
/// Returns `Some(snapshot_commit_id)` if it was created or `None` if nothing changed between the previous oplog
|
||||
/// commit and the current one (after comparing trees).
|
||||
pub(crate) fn commit_snapshot(
|
||||
&self,
|
||||
snapshot_tree_id: git::Oid,
|
||||
details: SnapshotDetails,
|
||||
) -> Result<Option<git::Oid>> {
|
||||
let repo = git2::Repository::open(self.path.as_path())?;
|
||||
let snapshot_tree = repo.find_tree(snapshot_tree_id.into())?;
|
||||
|
||||
let oplog_state = OplogHandle::new(&self.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) => Some(commit),
|
||||
Err(_) => None, // cant find the old one, start over
|
||||
},
|
||||
// This is the first snapshot - no parents
|
||||
None => None,
|
||||
};
|
||||
let oplog_head_commit = oplog_state
|
||||
.oplog_head()?
|
||||
.and_then(|head_id| repo.find_commit(head_id.into()).ok());
|
||||
|
||||
// Check if there is a difference between the tree and the parent tree, and if not, return so that we dont create noop snapshots
|
||||
if let Some(ref head_commit) = oplog_head_commit {
|
||||
if let Some(head_commit) = &oplog_head_commit {
|
||||
let parent_tree = head_commit.tree()?;
|
||||
let diff = repo.diff_tree_to_tree(Some(&parent_tree), Some(&tree), None)?;
|
||||
let diff = repo.diff_tree_to_tree(Some(&parent_tree), Some(&snapshot_tree), None)?;
|
||||
if diff.deltas().count() == 0 {
|
||||
return Ok(None);
|
||||
}
|
||||
}
|
||||
|
||||
// Construct a new commit
|
||||
let name = "GitButler";
|
||||
let email = "gitbutler@gitbutler.com";
|
||||
let signature = git2::Signature::now(name, email).unwrap();
|
||||
let parents = if let Some(ref oplog_head_commit) = oplog_head_commit {
|
||||
vec![oplog_head_commit]
|
||||
} else {
|
||||
vec![]
|
||||
};
|
||||
let new_commit_oid = repo.commit(
|
||||
let signature = git2::Signature::now(
|
||||
GITBUTLER_INTEGRATION_COMMIT_AUTHOR_NAME,
|
||||
GITBUTLER_INTEGRATION_COMMIT_AUTHOR_EMAIL,
|
||||
)
|
||||
.unwrap();
|
||||
let parents = oplog_head_commit
|
||||
.as_ref()
|
||||
.map(|head| vec![head])
|
||||
.unwrap_or_default();
|
||||
let snapshot_commit_id = repo.commit(
|
||||
None,
|
||||
&signature,
|
||||
&signature,
|
||||
&details.to_string(),
|
||||
&tree,
|
||||
&snapshot_tree,
|
||||
parents.as_slice(),
|
||||
)?;
|
||||
|
||||
oplog_state.set_oplog_head(new_commit_oid.to_string())?;
|
||||
oplog_state.set_oplog_head(snapshot_commit_id.into())?;
|
||||
|
||||
let vb_state = self.virtual_branches();
|
||||
// grab the target tree sha
|
||||
let default_target_sha = vb_state.get_default_target()?.sha;
|
||||
let target_commit_id = vb_state.get_default_target()?.sha;
|
||||
set_reference_to_oplog(&self.path, target_commit_id, snapshot_commit_id.into())?;
|
||||
|
||||
set_reference_to_oplog(
|
||||
self,
|
||||
&default_target_sha.to_string(),
|
||||
&new_commit_oid.to_string(),
|
||||
)?;
|
||||
|
||||
Ok(Some(new_commit_oid.to_string()))
|
||||
Ok(Some(snapshot_commit_id.into()))
|
||||
}
|
||||
|
||||
fn create_snapshot(&self, details: SnapshotDetails) -> Result<Option<String>> {
|
||||
/// Creates a snapshot of the current state of the working directory as well as GitButler data.
|
||||
/// This is a convenience method that combines [`prepare_snapshot`](Self::prepare_snapshot) and
|
||||
/// [`commit_snapshot`](Self::commit_snapshot).
|
||||
///
|
||||
/// Returns `Some(snapshot_commit_id)` if it was created or `None` if nothing changed between the previous oplog
|
||||
/// commit and the current one (after comparing trees).
|
||||
///
|
||||
/// Note that errors in snapshot creation is typically ignored, so we want to learn about them.
|
||||
#[instrument(skip(details), err(Debug))]
|
||||
pub fn create_snapshot(&self, details: SnapshotDetails) -> Result<Option<git::Oid>> {
|
||||
let tree_id = self.prepare_snapshot()?;
|
||||
self.commit_snapshot(tree_id, details)
|
||||
}
|
||||
|
||||
fn list_snapshots(&self, limit: usize, sha: Option<String>) -> Result<Vec<Snapshot>> {
|
||||
/// Lists the snapshots that have been created for the given repository, up to the given limit,
|
||||
/// and with the most recent snapshot first, and at the end of the vec.
|
||||
///
|
||||
/// Use `oplog_commit_id` if the traversal root for snapshot discovery should be the specified commit, which
|
||||
/// is usually obtained from a previous iteration. Useful along with `limit` to allow starting where the iteration
|
||||
/// left off. Note that the `oplog_commit_id` is always returned as first item in the result vec.
|
||||
///
|
||||
/// An alternative way of retrieving the snapshots would be to manually the oplog head `git log <oplog_head>` available in `.git/gitbutler/operations-log.toml`.
|
||||
///
|
||||
/// If there are no snapshots, an empty list is returned.
|
||||
pub fn list_snapshots(
|
||||
&self,
|
||||
limit: usize,
|
||||
oplog_commit_id: Option<git::Oid>,
|
||||
) -> Result<Vec<Snapshot>> {
|
||||
let repo_path = self.path.as_path();
|
||||
let repo = git2::Repository::init(repo_path)?;
|
||||
let repo = git2::Repository::open(repo_path)?;
|
||||
|
||||
let head_sha = match sha {
|
||||
let traversal_root_id = match oplog_commit_id {
|
||||
Some(sha) => sha,
|
||||
None => {
|
||||
let oplog_state = OplogHandle::new(&self.gb_dir());
|
||||
if let Some(sha) = oplog_state.get_oplog_head()? {
|
||||
sha
|
||||
if let Some(id) = oplog_state.oplog_head()? {
|
||||
id
|
||||
} else {
|
||||
// there are no snapshots so return an empty list
|
||||
return Ok(vec![]);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let oplog_head_commit = repo.find_commit(git2::Oid::from_str(&head_sha)?)?;
|
||||
let oplog_head_commit = repo.find_commit(traversal_root_id.into())?;
|
||||
|
||||
let mut revwalk = repo.revwalk()?;
|
||||
revwalk.push(oplog_head_commit.id())?;
|
||||
@ -335,6 +301,9 @@ impl Oplog for Project {
|
||||
let mut snapshots = Vec::new();
|
||||
|
||||
for commit_id in revwalk {
|
||||
if snapshots.len() == limit {
|
||||
break;
|
||||
}
|
||||
let commit_id = commit_id?;
|
||||
let commit = repo.find_commit(commit_id)?;
|
||||
|
||||
@ -343,11 +312,11 @@ impl Oplog for Project {
|
||||
}
|
||||
|
||||
let tree = commit.tree()?;
|
||||
let wd_tree_entry = tree.get_name("workdir");
|
||||
let tree = if let Some(wd_tree_entry) = wd_tree_entry {
|
||||
let wd_tree = if let Some(wd_tree_entry) = tree.get_name("workdir") {
|
||||
repo.find_tree(wd_tree_entry.id())?
|
||||
} else {
|
||||
// We reached a tree that is not a snapshot
|
||||
tracing::warn!("Commit {commit_id} didin't seem to be an oplog commit - skipping");
|
||||
continue;
|
||||
};
|
||||
|
||||
@ -356,14 +325,13 @@ impl Oplog for Project {
|
||||
.and_then(|msg| SnapshotDetails::from_str(msg).ok());
|
||||
|
||||
if let Ok(parent) = commit.parent(0) {
|
||||
let parent_tree = parent.tree()?;
|
||||
let parent_tree_entry = parent_tree.get_name("workdir");
|
||||
let parent_tree = parent_tree_entry
|
||||
let parent_tree = parent
|
||||
.tree()?
|
||||
.get_name("workdir")
|
||||
.map(|entry| repo.find_tree(entry.id()))
|
||||
.transpose()?;
|
||||
|
||||
let diff = repo.diff_tree_to_tree(parent_tree.as_ref(), Some(&tree), None)?;
|
||||
let stats = diff.stats()?;
|
||||
let diff = repo.diff_tree_to_tree(parent_tree.as_ref(), Some(&wd_tree), None)?;
|
||||
|
||||
let mut files_changed = Vec::new();
|
||||
diff.print(git2::DiffFormat::NameOnly, |delta, _, _| {
|
||||
@ -373,30 +341,24 @@ impl Oplog for Project {
|
||||
true
|
||||
})?;
|
||||
|
||||
let lines_added = stats.insertions();
|
||||
let lines_removed = stats.deletions();
|
||||
|
||||
let stats = diff.stats()?;
|
||||
snapshots.push(Snapshot {
|
||||
id: commit_id.to_string(),
|
||||
commit_id: commit_id.into(),
|
||||
details,
|
||||
lines_added,
|
||||
lines_removed,
|
||||
lines_added: stats.insertions(),
|
||||
lines_removed: stats.deletions(),
|
||||
files_changed,
|
||||
created_at: commit.time().seconds(),
|
||||
created_at: commit.time(),
|
||||
});
|
||||
|
||||
if snapshots.len() >= limit {
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
// this is the very first snapshot
|
||||
snapshots.push(Snapshot {
|
||||
id: commit_id.to_string(),
|
||||
commit_id: commit_id.into(),
|
||||
details,
|
||||
lines_added: 0,
|
||||
lines_removed: 0,
|
||||
files_changed: Vec::new(), // Fix: Change 0 to an empty vector
|
||||
created_at: commit.time().seconds(),
|
||||
files_changed: Vec::new(),
|
||||
created_at: commit.time(),
|
||||
});
|
||||
break;
|
||||
}
|
||||
@ -405,57 +367,63 @@ impl Oplog for Project {
|
||||
Ok(snapshots)
|
||||
}
|
||||
|
||||
fn restore_snapshot(&self, sha: String) -> Result<Option<String>> {
|
||||
let repo_path = self.path.as_path();
|
||||
let repo = git2::Repository::init(repo_path)?;
|
||||
/// Reverts to a previous state of the working directory, virtual branches and commits.
|
||||
/// The provided `snapshot_commit_id` must refer to a valid snapshot commit, as returned by [`create_snapshot`](Self::create_snapshot).
|
||||
/// Upon success, a new snapshot is created representing the state right before this call.
|
||||
///
|
||||
/// This will restore the following:
|
||||
/// - The state of the working directory is checked out from the subtree `workdir` in the snapshot.
|
||||
/// - The state of virtual branches is restored from the blob `virtual_branches.toml` in the snapshot.
|
||||
/// - The state of conflicts (.git/base_merge_parent and .git/conflicts) is restored from the subtree `conflicts` in the snapshot (if not present, existing files are deleted).
|
||||
///
|
||||
/// If there are files that are untracked and larger than `SNAPSHOT_FILE_LIMIT_BYTES`, they are excluded from snapshot creation and restoring.
|
||||
/// Returns the sha of the created revert snapshot commit or None if snapshots are disabled.
|
||||
pub fn restore_snapshot(&self, snapshot_commit_id: git::Oid) -> Result<Option<git::Oid>> {
|
||||
let worktree_dir = self.path.as_path();
|
||||
let repo = git2::Repository::open(worktree_dir)?;
|
||||
|
||||
// prepare snapshot
|
||||
let snapshot_tree = self.prepare_snapshot();
|
||||
let snapshot_tree_id = self.prepare_snapshot();
|
||||
let snapshot_commit = repo.find_commit(snapshot_commit_id.into())?;
|
||||
|
||||
let commit = repo.find_commit(git2::Oid::from_str(&sha)?)?;
|
||||
// Top tree
|
||||
let top_tree = commit.tree()?;
|
||||
let vb_tree_entry = top_tree
|
||||
let snapshot_tree = snapshot_commit.tree()?;
|
||||
let vb_toml_entry = snapshot_tree
|
||||
.get_name("virtual_branches.toml")
|
||||
.ok_or(anyhow!("failed to get virtual_branches.toml blob"))?;
|
||||
.context("failed to get virtual_branches.toml blob")?;
|
||||
// virtual_branches.toml blob
|
||||
let vb_blob = vb_tree_entry
|
||||
.to_object(&repo)?
|
||||
.into_blob()
|
||||
.map_err(|_| anyhow!("failed to convert virtual_branches tree entry to blob"))?;
|
||||
// Restore the state of .git/base_merge_parent and .git/conflicts from the snapshot
|
||||
// Will remove those files if they are not present in the snapshot
|
||||
_ = restore_conflicts_tree(&top_tree, &repo, repo_path);
|
||||
let wd_tree_entry = top_tree
|
||||
let vb_toml_blob = repo
|
||||
.find_blob(vb_toml_entry.id())
|
||||
.context("failed to convert virtual_branches tree entry to blob")?;
|
||||
|
||||
if let Err(err) = restore_conflicts_tree(&snapshot_tree, &repo) {
|
||||
tracing::warn!("failed to restore conflicts tree - ignoring: {err}")
|
||||
}
|
||||
let wd_entry = snapshot_tree
|
||||
.get_name("workdir")
|
||||
.ok_or(anyhow!("failed to get workdir tree entry"))?;
|
||||
.context("failed to get workdir tree entry")?;
|
||||
|
||||
// make sure we reconstitute any commits that were in the snapshot that are not here for some reason
|
||||
// for every entry in the virtual_branches subtree, reconsitute the commits
|
||||
let vb_tree_entry = top_tree
|
||||
let vb_tree_entry = snapshot_tree
|
||||
.get_name("virtual_branches")
|
||||
.ok_or(anyhow!("failed to get virtual_branches tree entry"))?;
|
||||
let vb_tree = vb_tree_entry
|
||||
.to_object(&repo)?
|
||||
.into_tree()
|
||||
.map_err(|_| anyhow!("failed to convert virtual_branches tree entry to tree"))?;
|
||||
.context("failed to get virtual_branches tree entry")?;
|
||||
let vb_tree = repo
|
||||
.find_tree(vb_tree_entry.id())
|
||||
.context("failed to convert virtual_branches tree entry to tree")?;
|
||||
|
||||
// walk through all the entries (branches)
|
||||
// walk through all the entries (branches by id)
|
||||
let walker = vb_tree.iter();
|
||||
for branch_entry in walker {
|
||||
let branch_tree = branch_entry
|
||||
.to_object(&repo)?
|
||||
.into_tree()
|
||||
.map_err(|_| anyhow!("failed to convert virtual_branches tree entry to tree"))?;
|
||||
let branch_tree = repo
|
||||
.find_tree(branch_entry.id())
|
||||
.context("failed to convert virtual_branches tree entry to tree")?;
|
||||
let branch_name = branch_entry.name();
|
||||
|
||||
let commits_tree_entry = branch_tree
|
||||
.get_name("commits")
|
||||
.ok_or(anyhow!("failed to get commits tree entry"))?;
|
||||
let commits_tree = commits_tree_entry
|
||||
.to_object(&repo)?
|
||||
.into_tree()
|
||||
.map_err(|_| anyhow!("failed to convert commits tree entry to tree"))?;
|
||||
.context("failed to get commits tree entry")?;
|
||||
let commits_tree = repo
|
||||
.find_tree(commits_tree_entry.id())
|
||||
.context("failed to convert commits tree entry to tree")?;
|
||||
|
||||
// walk through all the commits in the branch
|
||||
let commit_walker = commits_tree.iter();
|
||||
@ -463,39 +431,37 @@ impl Oplog for Project {
|
||||
// for each commit, recreate the commit from the commit data if it doesn't exist
|
||||
if let Some(commit_id) = commit_entry.name() {
|
||||
// check for the oid in the repo
|
||||
let commit_oid = git2::Oid::from_str(commit_id)?;
|
||||
if repo.find_commit(commit_oid).is_err() {
|
||||
let commit_oid = git::Oid::from_str(commit_id)?;
|
||||
if repo.find_commit(commit_oid.into()).is_err() {
|
||||
// commit is not in the repo, let's build it from our data
|
||||
// we get the data from the blob entry and create a commit object from it, which should match the oid of the entry
|
||||
let commit_tree = commit_entry
|
||||
.to_object(&repo)?
|
||||
.into_tree()
|
||||
.map_err(|_| anyhow!("failed to convert commit tree entry to tree"))?;
|
||||
let commit_tree = repo
|
||||
.find_tree(commit_entry.id())
|
||||
.context("failed to convert commit tree entry to tree")?;
|
||||
let commit_blob_entry = commit_tree
|
||||
.get_name("commit")
|
||||
.ok_or(anyhow!("failed to get workdir tree entry"))?;
|
||||
let commit_blob = commit_blob_entry
|
||||
.to_object(&repo)?
|
||||
.into_blob()
|
||||
.map_err(|_| anyhow!("failed to convert commit tree entry to blob"))?;
|
||||
.context("failed to get workdir tree entry")?;
|
||||
let commit_blob = repo
|
||||
.find_blob(commit_blob_entry.id())
|
||||
.context("failed to convert commit tree entry to blob")?;
|
||||
let new_commit_oid = repo
|
||||
.odb()?
|
||||
.write(git2::ObjectType::Commit, commit_blob.content())?;
|
||||
if new_commit_oid != commit_oid {
|
||||
return Err(anyhow!("commit oid mismatch"));
|
||||
if new_commit_oid != commit_oid.into() {
|
||||
bail!("commit oid mismatch");
|
||||
}
|
||||
}
|
||||
|
||||
// if branch_name is 'integration', we need to create or update the gitbutler/integration branch
|
||||
if let Some(branch_name) = branch_name {
|
||||
if branch_name == "integration" {
|
||||
let integration_commit = repo.find_commit(commit_oid)?;
|
||||
let integration_commit = repo.find_commit(commit_oid.into())?;
|
||||
// reset the branch if it's there
|
||||
let branch =
|
||||
repo.find_branch("gitbutler/integration", git2::BranchType::Local);
|
||||
if let Ok(mut branch) = branch {
|
||||
// need to detatch the head for just a minuto
|
||||
repo.set_head_detached(commit_oid)?;
|
||||
repo.set_head_detached(commit_oid.into())?;
|
||||
branch.delete()?;
|
||||
}
|
||||
// ok, now we set the branch to what it was and update HEAD
|
||||
@ -509,7 +475,7 @@ impl Oplog for Project {
|
||||
}
|
||||
|
||||
// workdir tree
|
||||
let work_tree = repo.find_tree(wd_tree_entry.id())?;
|
||||
let work_tree = repo.find_tree(wd_entry.id())?;
|
||||
|
||||
// Exclude files that are larger than the limit (eg. database.sql which may never be intended to be committed)
|
||||
let files_to_exclude = get_exclude_list(&repo)?;
|
||||
@ -525,41 +491,37 @@ impl Oplog for Project {
|
||||
|
||||
// Update virtual_branches.toml with the state from the snapshot
|
||||
fs::write(
|
||||
repo_path
|
||||
.join(".git")
|
||||
.join("gitbutler")
|
||||
.join("virtual_branches.toml"),
|
||||
vb_blob.content(),
|
||||
repo.path().join("gitbutler").join("virtual_branches.toml"),
|
||||
vb_toml_blob.content(),
|
||||
)?;
|
||||
|
||||
// reset the repo index to our index tree
|
||||
let index_tree_entry = top_tree
|
||||
let index_tree_entry = snapshot_tree
|
||||
.get_name("index")
|
||||
.ok_or(anyhow!("failed to get virtual_branches.toml blob"))?;
|
||||
let index_tree = index_tree_entry
|
||||
.to_object(&repo)?
|
||||
.into_tree()
|
||||
.map_err(|_| anyhow!("failed to convert index tree entry to tree"))?;
|
||||
.context("failed to get virtual_branches.toml blob")?;
|
||||
let index_tree = repo
|
||||
.find_tree(index_tree_entry.id())
|
||||
.context("failed to convert index tree entry to tree")?;
|
||||
let mut index = repo.index()?;
|
||||
index.read_tree(&index_tree)?;
|
||||
|
||||
let restored_operation = commit
|
||||
let restored_operation = snapshot_commit
|
||||
.message()
|
||||
.and_then(|msg| SnapshotDetails::from_str(msg).ok())
|
||||
.map(|d| d.operation.to_string())
|
||||
.unwrap_or_default();
|
||||
let restored_date = commit.time().seconds() * 1000;
|
||||
let restored_date = snapshot_commit.time().seconds() * 1000;
|
||||
|
||||
// create new snapshot
|
||||
let details = SnapshotDetails {
|
||||
version: Default::default(),
|
||||
operation: OperationType::RestoreFromSnapshot,
|
||||
operation: OperationKind::RestoreFromSnapshot,
|
||||
title: "Restored from snapshot".to_string(),
|
||||
body: None,
|
||||
trailers: vec![
|
||||
Trailer {
|
||||
key: "restored_from".to_string(),
|
||||
value: sha,
|
||||
value: snapshot_commit_id.to_string(),
|
||||
},
|
||||
Trailer {
|
||||
key: "restored_operation".to_string(),
|
||||
@ -571,38 +533,54 @@ impl Oplog for Project {
|
||||
},
|
||||
],
|
||||
};
|
||||
snapshot_tree.and_then(|snapshot_tree| self.commit_snapshot(snapshot_tree, details))
|
||||
snapshot_tree_id.and_then(|snapshot_tree| self.commit_snapshot(snapshot_tree, details))
|
||||
}
|
||||
|
||||
fn should_auto_snapshot(&self) -> Result<bool> {
|
||||
/// Determines if a new snapshot should be created due to file changes being created since the last snapshot.
|
||||
/// The needs for the automatic snapshotting are:
|
||||
/// - It needs to facilitate backup of work in progress code
|
||||
/// - The snapshots should not be too frequent or small - both for UX and performance reasons
|
||||
/// - Checking if an automatic snapshot is needed should be fast and efficient since it is called on filesystem events
|
||||
///
|
||||
/// Use `check_if_last_snapshot_older_than` as a way to control if the check should be performed at all, i.e.
|
||||
/// if this is 10s but the last snapshot was done 9s ago, no check if performed and the return value is `false`.
|
||||
///
|
||||
/// This implementation returns `true` on the following conditions:
|
||||
/// - If it's been more than 5 minutes since the last snapshot,
|
||||
/// check the sum of added and removed lines since the last snapshot, otherwise return `false`.
|
||||
/// * If the sum of added and removed lines is greater than a configured threshold, return `true`, otherwise return `false`.
|
||||
pub fn should_auto_snapshot(
|
||||
&self,
|
||||
check_if_last_snapshot_older_than: Duration,
|
||||
) -> Result<bool> {
|
||||
let oplog_state = OplogHandle::new(&self.gb_dir());
|
||||
let last_snapshot_time = oplog_state.get_modified_at()?;
|
||||
if last_snapshot_time.elapsed()? > Duration::from_secs(300) {
|
||||
let changed_lines = lines_since_snapshot(self)?;
|
||||
if changed_lines > self.snapshot_lines_threshold() {
|
||||
return Ok(true);
|
||||
}
|
||||
let last_snapshot_time = oplog_state.modified_at()?;
|
||||
let can_snapshot = if last_snapshot_time.elapsed()? <= check_if_last_snapshot_older_than {
|
||||
false
|
||||
} else {
|
||||
return Ok(false);
|
||||
}
|
||||
Ok(false)
|
||||
lines_since_snapshot(self)? > self.snapshot_lines_threshold()
|
||||
};
|
||||
Ok(can_snapshot)
|
||||
}
|
||||
|
||||
fn snapshot_diff(&self, sha: String) -> Result<HashMap<PathBuf, FileDiff>> {
|
||||
/// Returns the diff of the snapshot and it's parent. It only includes the workdir changes.
|
||||
///
|
||||
/// This is useful to show what has changed in this particular snapshot
|
||||
pub fn snapshot_diff(&self, sha: git::Oid) -> Result<HashMap<PathBuf, FileDiff>> {
|
||||
let repo_path = self.path.as_path();
|
||||
let repo = git2::Repository::init(repo_path)?;
|
||||
|
||||
let commit = repo.find_commit(git2::Oid::from_str(&sha)?)?;
|
||||
let commit = repo.find_commit(sha.into())?;
|
||||
// Top tree
|
||||
let tree = commit.tree()?;
|
||||
let old_tree = commit.parent(0)?.tree()?;
|
||||
|
||||
let wd_tree_entry = tree
|
||||
.get_name("workdir")
|
||||
.ok_or(anyhow!("failed to get workdir tree entry"))?;
|
||||
.context("failed to get workdir tree entry")?;
|
||||
let old_wd_tree_entry = old_tree
|
||||
.get_name("workdir")
|
||||
.ok_or(anyhow!("failed to get old workdir tree entry"))?;
|
||||
.context("failed to get old workdir tree entry")?;
|
||||
|
||||
// workdir tree
|
||||
let wd_tree = repo.find_tree(wd_tree_entry.id())?;
|
||||
@ -627,57 +605,59 @@ impl Oplog for Project {
|
||||
let hunks = hunks_by_filepath(None, &diff)?;
|
||||
Ok(hunks)
|
||||
}
|
||||
fn oplog_head(&self) -> Result<Option<String>> {
|
||||
|
||||
/// Gets the sha of the last snapshot commit if present.
|
||||
pub fn oplog_head(&self) -> Result<Option<git::Oid>> {
|
||||
let oplog_state = OplogHandle::new(&self.gb_dir());
|
||||
oplog_state.get_oplog_head()
|
||||
oplog_state.oplog_head()
|
||||
}
|
||||
}
|
||||
|
||||
fn restore_conflicts_tree(
|
||||
snapshot_tree: &git2::Tree,
|
||||
repo: &git2::Repository,
|
||||
repo_path: &std::path::Path,
|
||||
) -> Result<()> {
|
||||
// Restore the state of .git/base_merge_parent and .git/conflicts from the snapshot
|
||||
// Will remove those files if they are not present in the snapshot
|
||||
fn restore_conflicts_tree(snapshot_tree: &git2::Tree, repo: &git2::Repository) -> Result<()> {
|
||||
let conflicts_tree_entry = snapshot_tree
|
||||
.get_name("conflicts")
|
||||
.ok_or(anyhow!("failed to get conflicts tree entry"))?;
|
||||
let tree = repo.find_tree(conflicts_tree_entry.id())?;
|
||||
.context("failed to get conflicts tree entry")?;
|
||||
|
||||
let base_merge_parent_blob = tree.get_name("base_merge_parent");
|
||||
let path = repo_path.join(".git").join("base_merge_parent");
|
||||
if let Some(base_merge_parent_blob) = base_merge_parent_blob {
|
||||
let base_merge_parent_blob = base_merge_parent_blob
|
||||
.to_object(repo)?
|
||||
.into_blob()
|
||||
.map_err(|_| anyhow!("failed to convert base_merge_parent tree entry to blob"))?;
|
||||
fs::write(path, base_merge_parent_blob.content())?;
|
||||
} else if path.exists() {
|
||||
fs::remove_file(path)?;
|
||||
let conflicts_tree = repo.find_tree(conflicts_tree_entry.id())?;
|
||||
let base_merge_parent_entry = conflicts_tree.get_name("base_merge_parent");
|
||||
let base_merge_parent_path = repo.path().join("base_merge_parent");
|
||||
if let Some(base_merge_parent_blob) = base_merge_parent_entry {
|
||||
let base_merge_parent_blob = repo
|
||||
.find_blob(base_merge_parent_blob.id())
|
||||
.context("failed to convert base_merge_parent tree entry to blob")?;
|
||||
fs::write(base_merge_parent_path, base_merge_parent_blob.content())?;
|
||||
} else if base_merge_parent_path.exists() {
|
||||
fs::remove_file(base_merge_parent_path)?;
|
||||
}
|
||||
|
||||
let conflicts_blob = tree.get_name("conflicts");
|
||||
let path = repo_path.join(".git").join("conflicts");
|
||||
if let Some(conflicts_blob) = conflicts_blob {
|
||||
let conflicts_blob = conflicts_blob
|
||||
.to_object(repo)?
|
||||
.into_blob()
|
||||
.map_err(|_| anyhow!("failed to convert conflicts tree entry to blob"))?;
|
||||
fs::write(path, conflicts_blob.content())?;
|
||||
} else if path.exists() {
|
||||
fs::remove_file(path)?;
|
||||
let conflicts_entry = conflicts_tree.get_name("conflicts");
|
||||
let conflicts_path = repo.path().join("conflicts");
|
||||
if let Some(conflicts_entry) = conflicts_entry {
|
||||
let conflicts_blob = repo
|
||||
.find_blob(conflicts_entry.id())
|
||||
.context("failed to convert conflicts tree entry to blob")?;
|
||||
fs::write(conflicts_path, conflicts_blob.content())?;
|
||||
} else if conflicts_path.exists() {
|
||||
fs::remove_file(conflicts_path)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn write_conflicts_tree(repo_path: &std::path::Path, repo: &git2::Repository) -> Result<git2::Oid> {
|
||||
let merge_parent_path = repo_path.join(".git").join("base_merge_parent");
|
||||
fn write_conflicts_tree(
|
||||
worktree_dir: &std::path::Path,
|
||||
repo: &git2::Repository,
|
||||
) -> Result<git::Oid> {
|
||||
let git_dir = worktree_dir.join(".git");
|
||||
let merge_parent_path = git_dir.join("base_merge_parent");
|
||||
let merge_parent_blob = if merge_parent_path.exists() {
|
||||
let merge_parent_content = fs::read(merge_parent_path)?;
|
||||
Some(repo.blob(&merge_parent_content)?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let conflicts_path = repo_path.join(".git").join("conflicts");
|
||||
let conflicts_path = git_dir.join("conflicts");
|
||||
let conflicts_blob = if conflicts_path.exists() {
|
||||
let conflicts_content = fs::read(conflicts_path)?;
|
||||
Some(repo.blob(&conflicts_content)?)
|
||||
@ -696,14 +676,14 @@ fn write_conflicts_tree(repo_path: &std::path::Path, repo: &git2::Repository) ->
|
||||
tree_builder.insert("conflicts", conflicts_blob.unwrap(), FileMode::Blob.into())?;
|
||||
}
|
||||
let conflicts_tree = tree_builder.write()?;
|
||||
Ok(conflicts_tree)
|
||||
Ok(conflicts_tree.into())
|
||||
}
|
||||
|
||||
fn get_exclude_list(repo: &git2::Repository) -> Result<String> {
|
||||
let repo_path = repo
|
||||
.path()
|
||||
.parent()
|
||||
.ok_or(anyhow!("failed to get repo path"))?;
|
||||
.ok_or_else(|| anyhow!("failed to get repo path"))?;
|
||||
let statuses = repo.statuses(None)?;
|
||||
let mut files_to_exclude = vec![];
|
||||
for entry in statuses.iter() {
|
||||
@ -729,7 +709,7 @@ fn get_exclude_list(repo: &git2::Repository) -> Result<String> {
|
||||
Ok(files_to_exclude)
|
||||
}
|
||||
|
||||
/// Returns the number of lines of code (added plus removed) since the last snapshot. Includes untracked files.
|
||||
/// Returns the number of lines of code (added + removed) since the last snapshot. Includes untracked files.
|
||||
///
|
||||
/// If there are no snapshots, 0 is returned.
|
||||
fn lines_since_snapshot(project: &Project) -> Result<usize> {
|
||||
@ -737,8 +717,7 @@ fn lines_since_snapshot(project: &Project) -> Result<usize> {
|
||||
// and that same tree in the last snapshot. For some reason, comparing workdir to the workdir subree from
|
||||
// the snapshot simply does not give us what we need here, so instead using tree to tree comparison.
|
||||
|
||||
let repo_path = project.path.as_path();
|
||||
let repo = git2::Repository::init(repo_path)?;
|
||||
let repo = git2::Repository::open(project.path.as_path())?;
|
||||
|
||||
// Exclude files that are larger than the limit (eg. database.sql which may never be intended to be committed)
|
||||
let files_to_exclude = get_exclude_list(&repo)?;
|
||||
@ -746,16 +725,12 @@ fn lines_since_snapshot(project: &Project) -> Result<usize> {
|
||||
repo.add_ignore_rule(&files_to_exclude)?;
|
||||
|
||||
let oplog_state = OplogHandle::new(&project.gb_dir());
|
||||
let head_sha = oplog_state.get_oplog_head()?;
|
||||
if head_sha.is_none() {
|
||||
let Some(head_sha) = oplog_state.oplog_head()? else {
|
||||
return Ok(0);
|
||||
}
|
||||
let head_sha = head_sha.unwrap();
|
||||
};
|
||||
|
||||
let vb_state = project.virtual_branches();
|
||||
let binding = vb_state.list_branches()?;
|
||||
|
||||
let dirty_branches: Vec<&Branch> = binding
|
||||
let vbranches = project.virtual_branches().list_branches()?;
|
||||
let dirty_branches: Vec<&Branch> = vbranches
|
||||
.iter()
|
||||
.filter(|b| b.applied)
|
||||
.filter(|b| !b.ownership.claims.is_empty())
|
||||
@ -763,7 +738,7 @@ fn lines_since_snapshot(project: &Project) -> Result<usize> {
|
||||
|
||||
let mut lines_changed = 0;
|
||||
for branch in dirty_branches {
|
||||
lines_changed += branch_lines_since_snapshot(branch, &repo, head_sha.clone())?;
|
||||
lines_changed += branch_lines_since_snapshot(branch, &repo, head_sha)?;
|
||||
}
|
||||
Ok(lines_changed)
|
||||
}
|
||||
@ -771,23 +746,23 @@ fn lines_since_snapshot(project: &Project) -> Result<usize> {
|
||||
fn branch_lines_since_snapshot(
|
||||
branch: &Branch,
|
||||
repo: &git2::Repository,
|
||||
head_sha: String,
|
||||
head_sha: git::Oid,
|
||||
) -> Result<usize> {
|
||||
let active_branch_tree = repo.find_tree(branch.tree.into())?;
|
||||
|
||||
let commit = repo.find_commit(git2::Oid::from_str(&head_sha)?)?;
|
||||
let commit = repo.find_commit(head_sha.into())?;
|
||||
let head_tree = commit.tree()?;
|
||||
let virtual_branches = head_tree
|
||||
.get_name("virtual_branches")
|
||||
.ok_or(anyhow!("failed to get virtual_branches tree entry"))?;
|
||||
.ok_or_else(|| anyhow!("failed to get virtual_branches tree entry"))?;
|
||||
let virtual_branches = repo.find_tree(virtual_branches.id())?;
|
||||
let old_active_branch = virtual_branches
|
||||
.get_name(branch.id.to_string().as_str())
|
||||
.ok_or(anyhow!("failed to get active branch from tree entry"))?;
|
||||
.ok_or_else(|| anyhow!("failed to get active branch from tree entry"))?;
|
||||
let old_active_branch = repo.find_tree(old_active_branch.id())?;
|
||||
let old_active_branch_tree = old_active_branch
|
||||
.get_name("tree")
|
||||
.ok_or(anyhow!("failed to get integration tree entry"))?;
|
||||
.ok_or_else(|| anyhow!("failed to get integration tree entry"))?;
|
||||
let old_active_branch_tree = repo.find_tree(old_active_branch_tree.id())?;
|
||||
|
||||
let mut opts = git2::DiffOptions::new();
|
||||
@ -802,3 +777,9 @@ fn branch_lines_since_snapshot(
|
||||
let stats = diff?.stats()?;
|
||||
Ok(stats.deletions() + stats.insertions())
|
||||
}
|
||||
|
||||
fn serialize_commit(commit: &git2::Commit<'_>) -> Vec<u8> {
|
||||
let commit_header = commit.raw_header_bytes();
|
||||
let commit_message = commit.message_raw_bytes();
|
||||
[commit_header, b"\n", commit_message].concat()
|
||||
}
|
||||
|
@ -1,9 +1,12 @@
|
||||
use crate::fs::write;
|
||||
use anyhow::Result;
|
||||
use itertools::Itertools;
|
||||
use std::path::PathBuf;
|
||||
use crate::git;
|
||||
use anyhow::{Context, Result};
|
||||
use gix::config::tree::Key;
|
||||
use std::path::Path;
|
||||
|
||||
use crate::projects::Project;
|
||||
use crate::virtual_branches::integration::{
|
||||
GITBUTLER_INTEGRATION_COMMIT_AUTHOR_EMAIL, GITBUTLER_INTEGRATION_COMMIT_AUTHOR_NAME,
|
||||
};
|
||||
|
||||
/// 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:
|
||||
@ -13,20 +16,21 @@ use crate::projects::Project;
|
||||
/// 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:
|
||||
/// 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 ref originally pointed to the
|
||||
/// oplog head commit like so:
|
||||
///
|
||||
/// 0000000000000000000000000000000000000000 <target branch head sha>
|
||||
/// <target branch head sha> <oplog head sha>
|
||||
/// 0000000000000000000000000000000000000000 <target branch head>
|
||||
/// <target branch head> <oplog head>
|
||||
///
|
||||
/// 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,
|
||||
pub(super) fn set_reference_to_oplog(
|
||||
worktree_dir: &Path,
|
||||
target_commit_id: git::Oid,
|
||||
oplog_commit_id: git::Oid,
|
||||
) -> Result<()> {
|
||||
let repo_path = project.path.as_path();
|
||||
let reflog_file_path = repo_path
|
||||
let reflog_file_path = worktree_dir
|
||||
.join(".git")
|
||||
.join("logs")
|
||||
.join("refs")
|
||||
@ -34,146 +38,315 @@ pub fn set_reference_to_oplog(
|
||||
.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)?;
|
||||
let mut repo = gix::open_opts(
|
||||
worktree_dir,
|
||||
// We may override the username as we only write a specific commit log, unrelated to the user.
|
||||
gix::open::Options::isolated().config_overrides({
|
||||
let sig = standard_signature();
|
||||
[
|
||||
gix::config::tree::User::NAME.validated_assignment(sig.name)?,
|
||||
gix::config::tree::User::EMAIL.validated_assignment(sig.email)?,
|
||||
]
|
||||
}),
|
||||
)?;
|
||||
// The check is here only to avoid unnecessary writes
|
||||
if repo.try_find_reference("gitbutler/target")?.is_none() {
|
||||
repo.refs.write_reflog = gix::refs::store::WriteReflog::Always;
|
||||
let target_commit_hex = target_commit_id.to_string();
|
||||
repo.reference(
|
||||
"refs/heads/gitbutler/target",
|
||||
target_commit_hex.parse::<gix::ObjectId>()?,
|
||||
gix::refs::transaction::PreviousValue::Any,
|
||||
branch_creation_message(&target_commit_hex),
|
||||
)?;
|
||||
}
|
||||
|
||||
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)?;
|
||||
let mut content = std::fs::read_to_string(&reflog_file_path)
|
||||
.context("A reflog for gitbutler/target which is needed for undo snapshotting")?;
|
||||
content = set_target_ref(&content, &target_commit_id.to_string()).with_context(|| {
|
||||
format!(
|
||||
"Something was wrong with oplog reflog file at \"{}\"",
|
||||
reflog_file_path.display()
|
||||
)
|
||||
})?;
|
||||
content = set_oplog_ref(&content, &oplog_commit_id.to_string())?;
|
||||
write(reflog_file_path, content)?;
|
||||
|
||||
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 branch_creation_message(commit_id_hex: &str) -> String {
|
||||
format!("branch: Created from {commit_id_hex}")
|
||||
}
|
||||
|
||||
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);
|
||||
fn standard_signature() -> gix::actor::SignatureRef<'static> {
|
||||
gix::actor::SignatureRef {
|
||||
name: GITBUTLER_INTEGRATION_COMMIT_AUTHOR_NAME.into(),
|
||||
email: GITBUTLER_INTEGRATION_COMMIT_AUTHOR_EMAIL.into(),
|
||||
time: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
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(":");
|
||||
fn set_target_ref(reflog_content: &str, target_commit_id_hex: &str) -> Result<String> {
|
||||
// 0000000000000000000000000000000000000000 82873b54925ab268e9949557f28d070d388e7774 Kiril Videlov <kiril@videlov.com> 1714037434 +0200\tbranch: Created from 82873b54925ab268e9949557f28d070d388e7774
|
||||
let mut lines = gix::refs::file::log::iter::forward(reflog_content.as_bytes());
|
||||
let message = branch_creation_message(target_commit_id_hex);
|
||||
let expected_first_line = gix::refs::file::log::LineRef {
|
||||
previous_oid: "0000000000000000000000000000000000000000".into(),
|
||||
new_oid: target_commit_id_hex.into(),
|
||||
signature: standard_signature(),
|
||||
message: message.as_str().into(),
|
||||
};
|
||||
let mut first_line = lines
|
||||
.next()
|
||||
.unwrap_or(Ok(expected_first_line))
|
||||
.unwrap_or(expected_first_line);
|
||||
|
||||
let second_line = [target_ref, sha, &the_rest].join(" ");
|
||||
first_line.new_oid = target_commit_id_hex.into();
|
||||
let message = format!("branch: Created from {target_commit_id_hex}");
|
||||
first_line.message = message.as_str().into();
|
||||
|
||||
let content = format!("{}\n", [first_line, &second_line].join("\n"));
|
||||
write(file_path, content)
|
||||
Ok(serialize_line(first_line))
|
||||
}
|
||||
|
||||
fn set_oplog_ref(reflog_content: &str, oplog_commit_id_hex: &str) -> Result<String> {
|
||||
// 82873b54925ab268e9949557f28d070d388e7774 7e8eab472636a26611214bebea7d6b79c971fb8b Kiril Videlov <kiril@videlov.com> 1714044124 +0200\treset: moving to 7e8eab472636a26611214bebea7d6b79c971fb8b
|
||||
let mut lines = gix::refs::file::log::iter::forward(reflog_content.as_bytes());
|
||||
let first_line = lines.next().context("need the creation-line in reflog")??;
|
||||
|
||||
let new_msg = format!("reset: moving to {}", oplog_commit_id_hex);
|
||||
let second_line = gix::refs::file::log::LineRef {
|
||||
previous_oid: first_line.new_oid,
|
||||
new_oid: oplog_commit_id_hex.into(),
|
||||
message: new_msg.as_str().into(),
|
||||
..first_line
|
||||
};
|
||||
|
||||
Ok(format!(
|
||||
"{}\n{}\n",
|
||||
serialize_line(first_line),
|
||||
serialize_line(second_line)
|
||||
))
|
||||
}
|
||||
|
||||
fn serialize_line(line: gix::refs::file::log::LineRef<'_>) -> String {
|
||||
let mut sig = Vec::new();
|
||||
line.signature
|
||||
.write_to(&mut sig)
|
||||
.expect("write to memory succeeds");
|
||||
|
||||
format!(
|
||||
"{} {} {}\t{}",
|
||||
line.previous_oid,
|
||||
line.new_oid,
|
||||
std::str::from_utf8(&sig).expect("no illformed UTF8"),
|
||||
line.message
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
mod set_target_ref {
|
||||
use super::{
|
||||
git, set_reference_to_oplog, GITBUTLER_INTEGRATION_COMMIT_AUTHOR_EMAIL,
|
||||
GITBUTLER_INTEGRATION_COMMIT_AUTHOR_NAME,
|
||||
};
|
||||
use gix::refs::file::log::LineRef;
|
||||
use pretty_assertions::assert_eq;
|
||||
use std::path::PathBuf;
|
||||
use std::str::FromStr;
|
||||
use tempfile::tempdir;
|
||||
|
||||
#[test]
|
||||
fn test_set_target_ref() {
|
||||
let (dir, commit_id) = setup_repo();
|
||||
let project = Project {
|
||||
path: dir.path().to_path_buf(),
|
||||
..Default::default()
|
||||
};
|
||||
fn reflog_present_but_empty() -> anyhow::Result<()> {
|
||||
let (dir, commit_id) = setup_repo()?;
|
||||
let worktree_dir = dir.path();
|
||||
|
||||
let log_file_path = dir
|
||||
.path()
|
||||
.join(".git")
|
||||
.join("logs")
|
||||
.join("refs")
|
||||
.join("heads")
|
||||
.join("gitbutler")
|
||||
.join("target");
|
||||
let oplog = git::Oid::from_str("0123456789abcdef0123456789abcdef0123456")?;
|
||||
set_reference_to_oplog(worktree_dir, commit_id.into(), oplog).expect("success");
|
||||
|
||||
let log_file_path = worktree_dir.join(".git/logs/refs/heads/gitbutler/target");
|
||||
std::fs::write(&log_file_path, [])?;
|
||||
|
||||
set_reference_to_oplog(worktree_dir, commit_id.into(), oplog).expect("success");
|
||||
|
||||
let contents = std::fs::read_to_string(&log_file_path)?;
|
||||
assert_eq!(reflog_lines(&contents).len(), 2);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reflog_present_but_broken() -> anyhow::Result<()> {
|
||||
let (dir, commit_id) = setup_repo()?;
|
||||
let worktree_dir = dir.path();
|
||||
|
||||
let oplog = git::Oid::from_str("0123456789abcdef0123456789abcdef0123456")?;
|
||||
set_reference_to_oplog(worktree_dir, commit_id.into(), oplog).expect("success");
|
||||
|
||||
let log_file_path = worktree_dir.join(".git/logs/refs/heads/gitbutler/target");
|
||||
std::fs::write(&log_file_path, b"a gobbled mess that is no reflog")?;
|
||||
|
||||
set_reference_to_oplog(worktree_dir, commit_id.into(), oplog).expect("success");
|
||||
|
||||
let contents = std::fs::read_to_string(&log_file_path)?;
|
||||
assert_eq!(reflog_lines(&contents).len(), 2);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reflog_present_but_branch_is_missing() -> anyhow::Result<()> {
|
||||
let (dir, commit_id) = setup_repo()?;
|
||||
let worktree_dir = dir.path();
|
||||
|
||||
let oplog = git::Oid::from_str("0123456789abcdef0123456789abcdef0123456")?;
|
||||
set_reference_to_oplog(worktree_dir, commit_id.into(), oplog).expect("success");
|
||||
|
||||
let loose_ref_file = worktree_dir.join(".git/refs/heads/gitbutler/target");
|
||||
std::fs::remove_file(&loose_ref_file)?;
|
||||
|
||||
set_reference_to_oplog(worktree_dir, commit_id.into(), oplog).expect("success");
|
||||
assert!(
|
||||
loose_ref_file.is_file(),
|
||||
"the file was recreated, just in case there is only a reflog and no branch"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_and_update() -> anyhow::Result<()> {
|
||||
let (dir, commit_id) = setup_repo()?;
|
||||
let commit_id_hex = commit_id.to_string();
|
||||
let commit_id_hex: &gix::bstr::BStr = commit_id_hex.as_str().into();
|
||||
let worktree_dir = dir.path();
|
||||
|
||||
let log_file_path = worktree_dir.join(".git/logs/refs/heads/gitbutler/target");
|
||||
assert!(!log_file_path.exists());
|
||||
|
||||
// Set ref for the first time
|
||||
assert!(set_reference_to_oplog(&project, &commit_id.to_string(), "oplog_sha").is_ok());
|
||||
let oplog_hex = "0123456789abcdef0123456789abcdef01234567";
|
||||
let oplog = git::Oid::from_str(oplog_hex)?;
|
||||
set_reference_to_oplog(worktree_dir, commit_id.into(), oplog).expect("success");
|
||||
assert!(log_file_path.exists());
|
||||
let log_file = std::fs::read_to_string(&log_file_path).unwrap();
|
||||
let log_lines = log_file.lines().collect::<Vec<_>>();
|
||||
assert_eq!(log_lines.len(), 2);
|
||||
assert!(log_lines[0].starts_with(&format!(
|
||||
"0000000000000000000000000000000000000000 {}",
|
||||
commit_id
|
||||
)));
|
||||
assert!(log_lines[0].ends_with(&format!("branch: Created from {}", commit_id)));
|
||||
assert!(log_lines[1].starts_with(&format!("{} {}", commit_id, "oplog_sha")));
|
||||
assert!(log_lines[1].ends_with("reset: moving to oplog_sha"));
|
||||
let contents = std::fs::read_to_string(&log_file_path)?;
|
||||
let lines = reflog_lines(&contents);
|
||||
assert_eq!(
|
||||
lines.len(),
|
||||
2,
|
||||
"lines parse and it's exactly two, one for branch creation, another for oplog id"
|
||||
);
|
||||
|
||||
let first_line = lines[0];
|
||||
assert_signature(first_line.signature);
|
||||
let first_line_message = format!("branch: Created from {}", commit_id);
|
||||
let expected_line = gix::refs::file::log::LineRef {
|
||||
previous_oid: "0000000000000000000000000000000000000000".into(),
|
||||
new_oid: commit_id_hex,
|
||||
signature: first_line.signature,
|
||||
message: first_line_message.as_str().into(),
|
||||
};
|
||||
assert_eq!(first_line, expected_line);
|
||||
|
||||
let second_line = lines[1];
|
||||
let second_line_message = format!("reset: moving to {oplog}");
|
||||
let expected_line = gix::refs::file::log::LineRef {
|
||||
previous_oid: commit_id_hex,
|
||||
new_oid: oplog_hex.into(),
|
||||
signature: first_line.signature,
|
||||
message: second_line_message.as_str().into(),
|
||||
};
|
||||
assert_eq!(second_line, expected_line);
|
||||
|
||||
// Update the oplog head only
|
||||
assert!(
|
||||
set_reference_to_oplog(&project, &commit_id.to_string(), "another_oplog_sha").is_ok()
|
||||
);
|
||||
let log_file = std::fs::read_to_string(&log_file_path).unwrap();
|
||||
let log_lines = log_file.lines().collect::<Vec<_>>();
|
||||
assert_eq!(log_lines.len(), 2);
|
||||
assert!(log_lines[0].starts_with(&format!(
|
||||
"0000000000000000000000000000000000000000 {}",
|
||||
commit_id
|
||||
)));
|
||||
assert!(log_lines[0].ends_with(&format!("branch: Created from {}", commit_id)));
|
||||
println!("{:?}", log_lines[1]);
|
||||
assert!(log_lines[1].starts_with(&format!("{} {}", commit_id, "another_oplog_sha")));
|
||||
assert!(log_lines[1].ends_with("reset: moving to another_oplog_sha"));
|
||||
let another_oplog_hex = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb";
|
||||
let another_oplog = git::Oid::from_str(another_oplog_hex)?;
|
||||
set_reference_to_oplog(worktree_dir, commit_id.into(), another_oplog).expect("success");
|
||||
|
||||
let contents = std::fs::read_to_string(&log_file_path)?;
|
||||
let lines: Vec<_> = reflog_lines(&contents);
|
||||
assert_eq!(lines.len(), 2);
|
||||
|
||||
let first_line = lines[0];
|
||||
assert_signature(first_line.signature);
|
||||
let expected_line = gix::refs::file::log::LineRef {
|
||||
previous_oid: "0000000000000000000000000000000000000000".into(),
|
||||
new_oid: commit_id_hex,
|
||||
signature: first_line.signature,
|
||||
message: first_line_message.as_str().into(),
|
||||
};
|
||||
assert_eq!(first_line, expected_line);
|
||||
|
||||
let second_line = lines[1];
|
||||
let second_line_message = format!("reset: moving to {another_oplog}");
|
||||
let expected_line = gix::refs::file::log::LineRef {
|
||||
previous_oid: commit_id_hex,
|
||||
new_oid: another_oplog_hex.into(),
|
||||
signature: first_line.signature,
|
||||
message: second_line_message.as_str().into(),
|
||||
};
|
||||
assert_eq!(second_line, expected_line);
|
||||
|
||||
// Update the target head only
|
||||
assert!(set_reference_to_oplog(&project, "new_target", "another_oplog_sha").is_ok());
|
||||
let log_file = std::fs::read_to_string(&log_file_path).unwrap();
|
||||
let log_lines = log_file.lines().collect::<Vec<_>>();
|
||||
assert_eq!(log_lines.len(), 2);
|
||||
assert!(log_lines[0].starts_with(&format!(
|
||||
"0000000000000000000000000000000000000000 {}",
|
||||
"new_target"
|
||||
)));
|
||||
assert!(log_lines[0].ends_with(&format!("branch: Created from {}", "new_target")));
|
||||
println!("{:?}", log_lines[1]);
|
||||
assert!(log_lines[1].starts_with(&format!("{} {}", "new_target", "another_oplog_sha")));
|
||||
assert!(log_lines[1].ends_with("reset: moving to another_oplog_sha"));
|
||||
let new_target_hex = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
|
||||
let new_target = git::Oid::from_str(new_target_hex)?;
|
||||
set_reference_to_oplog(worktree_dir, new_target, another_oplog).expect("success");
|
||||
|
||||
let contents = std::fs::read_to_string(&log_file_path)?;
|
||||
let lines: Vec<_> = reflog_lines(&contents);
|
||||
assert_eq!(lines.len(), 2);
|
||||
|
||||
let first_line = lines[0];
|
||||
assert_signature(first_line.signature);
|
||||
let first_line_message = format!("branch: Created from {new_target}");
|
||||
let expected_line = gix::refs::file::log::LineRef {
|
||||
previous_oid: "0000000000000000000000000000000000000000".into(),
|
||||
new_oid: new_target_hex.into(),
|
||||
signature: first_line.signature,
|
||||
message: first_line_message.as_str().into(),
|
||||
};
|
||||
assert_eq!(first_line, expected_line);
|
||||
|
||||
let second_line = lines[1];
|
||||
assert_signature(second_line.signature);
|
||||
let expected_line = gix::refs::file::log::LineRef {
|
||||
previous_oid: new_target_hex.into(),
|
||||
new_oid: another_oplog_hex.into(),
|
||||
signature: first_line.signature,
|
||||
message: second_line_message.as_str().into(),
|
||||
};
|
||||
assert_eq!(second_line, expected_line);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn setup_repo() -> (tempfile::TempDir, git2::Oid) {
|
||||
let dir = tempdir().unwrap();
|
||||
let repo = git2::Repository::init(dir.path()).unwrap();
|
||||
fn reflog_lines(contents: &str) -> Vec<LineRef<'_>> {
|
||||
gix::refs::file::log::iter::forward(contents.as_bytes())
|
||||
.map(Result::unwrap)
|
||||
.collect::<Vec<_>>()
|
||||
}
|
||||
|
||||
fn assert_signature(sig: gix::actor::SignatureRef<'_>) {
|
||||
assert_eq!(sig.name, GITBUTLER_INTEGRATION_COMMIT_AUTHOR_NAME);
|
||||
assert_eq!(sig.email, GITBUTLER_INTEGRATION_COMMIT_AUTHOR_EMAIL);
|
||||
}
|
||||
|
||||
fn setup_repo() -> anyhow::Result<(tempfile::TempDir, git2::Oid)> {
|
||||
let dir = tempdir()?;
|
||||
let repo = git2::Repository::init(dir.path())?;
|
||||
let file_path = dir.path().join("foo.txt");
|
||||
std::fs::write(file_path, "test").unwrap();
|
||||
let mut index = repo.index().unwrap();
|
||||
index.add_path(&PathBuf::from("foo.txt")).unwrap();
|
||||
let oid = index.write_tree().unwrap();
|
||||
std::fs::write(file_path, "test")?;
|
||||
let mut index = repo.index()?;
|
||||
index.add_path(&PathBuf::from("foo.txt"))?;
|
||||
let oid = index.write_tree()?;
|
||||
let name = "Your Name";
|
||||
let email = "your.email@example.com";
|
||||
let signature = git2::Signature::now(name, email).unwrap();
|
||||
let commit_id = repo
|
||||
.commit(
|
||||
Some("HEAD"),
|
||||
&signature,
|
||||
&signature,
|
||||
"initial commit",
|
||||
&repo.find_tree(oid).unwrap(),
|
||||
&[],
|
||||
)
|
||||
.unwrap();
|
||||
(dir, commit_id)
|
||||
let signature = git2::Signature::now(name, email)?;
|
||||
let commit_id = repo.commit(
|
||||
Some("HEAD"),
|
||||
&signature,
|
||||
&signature,
|
||||
"initial commit",
|
||||
&repo.find_tree(oid)?,
|
||||
&[],
|
||||
)?;
|
||||
Ok((dir, commit_id))
|
||||
}
|
||||
}
|
||||
|
@ -1,90 +1,74 @@
|
||||
use std::vec;
|
||||
|
||||
use crate::projects::Project;
|
||||
use crate::{
|
||||
ops::entry::{OperationType, SnapshotDetails},
|
||||
git,
|
||||
ops::entry::{OperationKind, SnapshotDetails},
|
||||
virtual_branches::{branch::BranchUpdateRequest, Branch},
|
||||
};
|
||||
|
||||
use super::{entry::Trailer, oplog::Oplog};
|
||||
use super::entry::Trailer;
|
||||
|
||||
pub trait Snapshot {
|
||||
fn snapshot_branch_creation(&self, branch_name: String) -> anyhow::Result<()>;
|
||||
fn snapshot_branch_deletion(&self, branch_name: String) -> anyhow::Result<()>;
|
||||
fn snapshot_branch_applied(&self, branch_name: String) -> anyhow::Result<()>;
|
||||
fn snapshot_branch_unapplied(&self, branch_name: String) -> anyhow::Result<()>;
|
||||
fn snapshot_branch_update(
|
||||
&self,
|
||||
old_branch: &Branch,
|
||||
update: &BranchUpdateRequest,
|
||||
) -> anyhow::Result<()>;
|
||||
fn snapshot_commit_creation(
|
||||
&self,
|
||||
snapshot_tree: String,
|
||||
commit_message: String,
|
||||
sha: Option<String>,
|
||||
) -> anyhow::Result<()>;
|
||||
fn snapshot_commit_undo(&self, commit_sha: String) -> anyhow::Result<()>;
|
||||
}
|
||||
|
||||
impl<T: Oplog> Snapshot for T {
|
||||
fn snapshot_branch_applied(&self, branch_name: String) -> anyhow::Result<()> {
|
||||
/// Snapshot functionality
|
||||
impl Project {
|
||||
pub(crate) fn snapshot_branch_applied(&self, branch_name: String) -> anyhow::Result<()> {
|
||||
let details =
|
||||
SnapshotDetails::new(OperationType::ApplyBranch).with_trailers(vec![Trailer {
|
||||
SnapshotDetails::new(OperationKind::ApplyBranch).with_trailers(vec![Trailer {
|
||||
key: "name".to_string(),
|
||||
value: branch_name,
|
||||
}]);
|
||||
self.create_snapshot(details)?;
|
||||
Ok(())
|
||||
}
|
||||
fn snapshot_branch_unapplied(&self, branch_name: String) -> anyhow::Result<()> {
|
||||
pub(crate) fn snapshot_branch_unapplied(&self, branch_name: String) -> anyhow::Result<()> {
|
||||
let details =
|
||||
SnapshotDetails::new(OperationType::UnapplyBranch).with_trailers(vec![Trailer {
|
||||
SnapshotDetails::new(OperationKind::UnapplyBranch).with_trailers(vec![Trailer {
|
||||
key: "name".to_string(),
|
||||
value: branch_name,
|
||||
}]);
|
||||
self.create_snapshot(details)?;
|
||||
Ok(())
|
||||
}
|
||||
fn snapshot_commit_undo(&self, commit_sha: String) -> anyhow::Result<()> {
|
||||
pub(crate) fn snapshot_commit_undo(&self, commit_sha: git::Oid) -> anyhow::Result<()> {
|
||||
let details =
|
||||
SnapshotDetails::new(OperationType::UndoCommit).with_trailers(vec![Trailer {
|
||||
SnapshotDetails::new(OperationKind::UndoCommit).with_trailers(vec![Trailer {
|
||||
key: "sha".to_string(),
|
||||
value: commit_sha,
|
||||
value: commit_sha.to_string(),
|
||||
}]);
|
||||
self.create_snapshot(details)?;
|
||||
Ok(())
|
||||
}
|
||||
fn snapshot_commit_creation(
|
||||
pub(crate) fn snapshot_commit_creation(
|
||||
&self,
|
||||
snapshot_tree: String,
|
||||
snapshot_tree: git::Oid,
|
||||
commit_message: String,
|
||||
sha: Option<String>,
|
||||
sha: Option<git::Oid>,
|
||||
) -> anyhow::Result<()> {
|
||||
let details = SnapshotDetails::new(OperationType::CreateCommit).with_trailers(vec![
|
||||
let details = SnapshotDetails::new(OperationKind::CreateCommit).with_trailers(vec![
|
||||
Trailer {
|
||||
key: "message".to_string(),
|
||||
value: commit_message,
|
||||
},
|
||||
Trailer {
|
||||
key: "sha".to_string(),
|
||||
value: sha.unwrap_or_default(),
|
||||
value: sha.map(|sha| sha.to_string()).unwrap_or_default(),
|
||||
},
|
||||
]);
|
||||
self.commit_snapshot(snapshot_tree, details)?;
|
||||
Ok(())
|
||||
}
|
||||
fn snapshot_branch_creation(&self, branch_name: String) -> anyhow::Result<()> {
|
||||
pub(crate) fn snapshot_branch_creation(&self, branch_name: String) -> anyhow::Result<()> {
|
||||
let details =
|
||||
SnapshotDetails::new(OperationType::CreateBranch).with_trailers(vec![Trailer {
|
||||
SnapshotDetails::new(OperationKind::CreateBranch).with_trailers(vec![Trailer {
|
||||
key: "name".to_string(),
|
||||
value: branch_name,
|
||||
}]);
|
||||
self.create_snapshot(details)?;
|
||||
Ok(())
|
||||
}
|
||||
fn snapshot_branch_deletion(&self, branch_name: String) -> anyhow::Result<()> {
|
||||
pub(crate) fn snapshot_branch_deletion(&self, branch_name: String) -> anyhow::Result<()> {
|
||||
let details =
|
||||
SnapshotDetails::new(OperationType::DeleteBranch).with_trailers(vec![Trailer {
|
||||
SnapshotDetails::new(OperationKind::DeleteBranch).with_trailers(vec![Trailer {
|
||||
key: "name".to_string(),
|
||||
value: branch_name.to_string(),
|
||||
}]);
|
||||
@ -92,31 +76,31 @@ impl<T: Oplog> Snapshot for T {
|
||||
self.create_snapshot(details)?;
|
||||
Ok(())
|
||||
}
|
||||
fn snapshot_branch_update(
|
||||
pub(crate) fn snapshot_branch_update(
|
||||
&self,
|
||||
old_branch: &Branch,
|
||||
update: &BranchUpdateRequest,
|
||||
) -> anyhow::Result<()> {
|
||||
let details = if update.ownership.is_some() {
|
||||
SnapshotDetails::new(OperationType::MoveHunk).with_trailers(vec![Trailer {
|
||||
SnapshotDetails::new(OperationKind::MoveHunk).with_trailers(vec![Trailer {
|
||||
key: "name".to_string(),
|
||||
value: old_branch.name.to_string(),
|
||||
}])
|
||||
} else if let Some(name) = update.name.clone() {
|
||||
SnapshotDetails::new(OperationType::UpdateBranchName).with_trailers(vec![
|
||||
} else if let Some(name) = update.name.as_deref() {
|
||||
SnapshotDetails::new(OperationKind::UpdateBranchName).with_trailers(vec![
|
||||
Trailer {
|
||||
key: "before".to_string(),
|
||||
value: old_branch.name.to_string(),
|
||||
value: old_branch.name.clone(),
|
||||
},
|
||||
Trailer {
|
||||
key: "after".to_string(),
|
||||
value: name,
|
||||
value: name.to_owned(),
|
||||
},
|
||||
])
|
||||
} else if update.notes.is_some() {
|
||||
SnapshotDetails::new(OperationType::UpdateBranchNotes)
|
||||
SnapshotDetails::new(OperationKind::UpdateBranchNotes)
|
||||
} else if let Some(order) = update.order {
|
||||
SnapshotDetails::new(OperationType::ReorderBranches).with_trailers(vec![
|
||||
SnapshotDetails::new(OperationKind::ReorderBranches).with_trailers(vec![
|
||||
Trailer {
|
||||
key: "before".to_string(),
|
||||
value: old_branch.order.to_string(),
|
||||
@ -127,7 +111,7 @@ impl<T: Oplog> Snapshot for T {
|
||||
},
|
||||
])
|
||||
} else if let Some(_selected_for_changes) = update.selected_for_changes {
|
||||
SnapshotDetails::new(OperationType::SelectDefaultVirtualBranch).with_trailers(vec![
|
||||
SnapshotDetails::new(OperationKind::SelectDefaultVirtualBranch).with_trailers(vec![
|
||||
Trailer {
|
||||
key: "before".to_string(),
|
||||
value: old_branch
|
||||
@ -140,23 +124,23 @@ impl<T: Oplog> Snapshot for T {
|
||||
value: old_branch.name.clone(),
|
||||
},
|
||||
])
|
||||
} else if let Some(upstream) = update.upstream.clone() {
|
||||
SnapshotDetails::new(OperationType::UpdateBranchRemoteName).with_trailers(vec![
|
||||
} else if let Some(upstream) = update.upstream.as_deref() {
|
||||
SnapshotDetails::new(OperationKind::UpdateBranchRemoteName).with_trailers(vec![
|
||||
Trailer {
|
||||
key: "before".to_string(),
|
||||
value: old_branch
|
||||
.upstream
|
||||
.clone()
|
||||
.as_ref()
|
||||
.map(|r| r.to_string())
|
||||
.unwrap_or("".to_string()),
|
||||
.unwrap_or_default(),
|
||||
},
|
||||
Trailer {
|
||||
key: "after".to_string(),
|
||||
value: upstream,
|
||||
value: upstream.to_owned(),
|
||||
},
|
||||
])
|
||||
} else {
|
||||
SnapshotDetails::new(OperationType::GenericBranchUpdate)
|
||||
SnapshotDetails::new(OperationKind::GenericBranchUpdate)
|
||||
};
|
||||
self.create_snapshot(details)?;
|
||||
Ok(())
|
||||
|
@ -1,16 +1,16 @@
|
||||
use anyhow::Result;
|
||||
use std::{
|
||||
fs::File,
|
||||
io::Read,
|
||||
path::{Path, PathBuf},
|
||||
time::SystemTime,
|
||||
};
|
||||
|
||||
use crate::fs::read_toml_file_or_default;
|
||||
use crate::git;
|
||||
use serde::{Deserialize, Deserializer, Serialize};
|
||||
|
||||
use super::OPLOG_FILE_NAME;
|
||||
|
||||
/// SystemTime used to be serialized as a u64 of seconds, but is now a propper SystemTime struct.
|
||||
/// SystemTime used to be serialized as u64 of seconds, but is now a proper SystemTime struct.
|
||||
/// This function will handle the old format gracefully.
|
||||
fn unfailing_system_time_deserialize<'de, D>(deserializer: D) -> Result<SystemTime, D::Error>
|
||||
where
|
||||
@ -27,7 +27,8 @@ fn unix_epoch() -> SystemTime {
|
||||
#[derive(Serialize, Deserialize, Debug)]
|
||||
pub struct Oplog {
|
||||
/// This is the sha of the last oplog commit
|
||||
pub head_sha: Option<String>,
|
||||
#[serde(with = "crate::serde::oid_opt")]
|
||||
pub head_sha: Option<git::Oid>,
|
||||
/// The time when the last snapshot was created. Seconds since Epoch
|
||||
#[serde(
|
||||
deserialize_with = "unfailing_system_time_deserialize",
|
||||
@ -45,7 +46,7 @@ impl Default for Oplog {
|
||||
}
|
||||
}
|
||||
|
||||
pub struct OplogHandle {
|
||||
pub(crate) struct OplogHandle {
|
||||
/// The path to the file containing the oplog head state.
|
||||
file_path: PathBuf,
|
||||
}
|
||||
@ -60,7 +61,7 @@ impl OplogHandle {
|
||||
/// 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<()> {
|
||||
pub fn set_oplog_head(&self, sha: git::Oid) -> Result<()> {
|
||||
let mut oplog = self.read_file()?;
|
||||
oplog.head_sha = Some(sha);
|
||||
self.write_file(oplog)?;
|
||||
@ -70,7 +71,7 @@ impl OplogHandle {
|
||||
/// 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>> {
|
||||
pub fn oplog_head(&self) -> Result<Option<git::Oid>> {
|
||||
let oplog = self.read_file()?;
|
||||
Ok(oplog.head_sha)
|
||||
}
|
||||
@ -78,7 +79,7 @@ impl OplogHandle {
|
||||
/// Gets the time when the last snapshot was created.
|
||||
///
|
||||
/// Errors if the file cannot be read or written.
|
||||
pub fn get_modified_at(&self) -> anyhow::Result<SystemTime> {
|
||||
pub fn modified_at(&self) -> Result<SystemTime> {
|
||||
let oplog = self.read_file()?;
|
||||
Ok(oplog.modified_at)
|
||||
}
|
||||
@ -87,30 +88,11 @@ impl OplogHandle {
|
||||
///
|
||||
/// 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)
|
||||
Ok(read_toml_file_or_default(&self.file_path)?)
|
||||
}
|
||||
|
||||
fn write_file(&self, oplog: Oplog) -> anyhow::Result<()> {
|
||||
let mut oplog = oplog;
|
||||
let now = std::time::SystemTime::now();
|
||||
oplog.modified_at = now;
|
||||
write(self.file_path.as_path(), &oplog)
|
||||
fn write_file(&self, mut oplog: Oplog) -> Result<()> {
|
||||
oplog.modified_at = SystemTime::now();
|
||||
crate::fs::write(&self.file_path, toml::to_string(&oplog)?)
|
||||
}
|
||||
}
|
||||
|
||||
fn write<P: AsRef<Path>>(file_path: P, oplog: &Oplog) -> anyhow::Result<()> {
|
||||
let contents = toml::to_string(&oplog)?;
|
||||
crate::fs::write(file_path, contents)
|
||||
}
|
||||
|
@ -68,6 +68,8 @@ pub struct Project {
|
||||
pub title: String,
|
||||
pub description: Option<String>,
|
||||
/// The worktree directory of the project's repository.
|
||||
// TODO(ST): rename this to `worktree_dir` and while at it, add a `git_dir` if it's retrieved from a repo.
|
||||
// Then find `.join(".git")` and use the `git_dir` instead.
|
||||
pub path: path::PathBuf,
|
||||
#[serde(default)]
|
||||
pub preferred_key: AuthKey,
|
||||
|
@ -1,7 +1,6 @@
|
||||
use std::time;
|
||||
|
||||
use crate::id::Id;
|
||||
use crate::ops::oplog::Oplog;
|
||||
use crate::{
|
||||
git::{self, Oid},
|
||||
project_repository,
|
||||
|
@ -1,10 +1,6 @@
|
||||
use crate::{
|
||||
error::Error,
|
||||
ops::{
|
||||
entry::{OperationType, SnapshotDetails},
|
||||
oplog::Oplog,
|
||||
snapshot::Snapshot,
|
||||
},
|
||||
ops::entry::{OperationKind, SnapshotDetails},
|
||||
};
|
||||
use std::{collections::HashMap, path::Path, sync::Arc};
|
||||
|
||||
@ -464,7 +460,7 @@ impl ControllerInner {
|
||||
project_repository.project().snapshot_commit_creation(
|
||||
snapshot_tree,
|
||||
message.to_owned(),
|
||||
Some("".to_string()),
|
||||
None,
|
||||
)
|
||||
});
|
||||
result
|
||||
@ -558,7 +554,7 @@ impl ControllerInner {
|
||||
let project_repository = project_repository::Repository::open(&project)?;
|
||||
let _ = project_repository
|
||||
.project()
|
||||
.create_snapshot(SnapshotDetails::new(OperationType::SetBaseBranch));
|
||||
.create_snapshot(SnapshotDetails::new(OperationKind::SetBaseBranch));
|
||||
let result = super::set_base_branch(&project_repository, target_branch)?;
|
||||
Ok(result)
|
||||
}
|
||||
@ -584,7 +580,7 @@ impl ControllerInner {
|
||||
self.with_verify_branch(project_id, |project_repository, user| {
|
||||
let _ = project_repository
|
||||
.project()
|
||||
.create_snapshot(SnapshotDetails::new(OperationType::MergeUpstream));
|
||||
.create_snapshot(SnapshotDetails::new(OperationKind::MergeUpstream));
|
||||
super::integrate_upstream_commits(project_repository, branch_id, user)
|
||||
.map_err(Into::into)
|
||||
})
|
||||
@ -596,7 +592,7 @@ impl ControllerInner {
|
||||
self.with_verify_branch(project_id, |project_repository, user| {
|
||||
let _ = project_repository
|
||||
.project()
|
||||
.create_snapshot(SnapshotDetails::new(OperationType::UpdateWorkspaceBase));
|
||||
.create_snapshot(SnapshotDetails::new(OperationKind::UpdateWorkspaceBase));
|
||||
super::update_base_branch(project_repository, user).map_err(Into::into)
|
||||
})
|
||||
}
|
||||
@ -648,7 +644,7 @@ impl ControllerInner {
|
||||
self.with_verify_branch(project_id, |project_repository, _| {
|
||||
let _ = project_repository
|
||||
.project()
|
||||
.create_snapshot(SnapshotDetails::new(OperationType::DiscardHunk));
|
||||
.create_snapshot(SnapshotDetails::new(OperationKind::DiscardHunk));
|
||||
super::unapply_ownership(project_repository, ownership).map_err(Into::into)
|
||||
})
|
||||
}
|
||||
@ -663,7 +659,7 @@ impl ControllerInner {
|
||||
self.with_verify_branch(project_id, |project_repository, _| {
|
||||
let _ = project_repository
|
||||
.project()
|
||||
.create_snapshot(SnapshotDetails::new(OperationType::DiscardFile));
|
||||
.create_snapshot(SnapshotDetails::new(OperationKind::DiscardFile));
|
||||
super::reset_files(project_repository, ownership).map_err(Into::into)
|
||||
})
|
||||
}
|
||||
@ -680,7 +676,7 @@ impl ControllerInner {
|
||||
self.with_verify_branch(project_id, |project_repository, _| {
|
||||
let _ = project_repository
|
||||
.project()
|
||||
.create_snapshot(SnapshotDetails::new(OperationType::AmendCommit));
|
||||
.create_snapshot(SnapshotDetails::new(OperationKind::AmendCommit));
|
||||
super::amend(project_repository, branch_id, commit_oid, ownership).map_err(Into::into)
|
||||
})
|
||||
}
|
||||
@ -698,7 +694,7 @@ impl ControllerInner {
|
||||
self.with_verify_branch(project_id, |project_repository, _| {
|
||||
let _ = project_repository
|
||||
.project()
|
||||
.create_snapshot(SnapshotDetails::new(OperationType::MoveCommitFile));
|
||||
.create_snapshot(SnapshotDetails::new(OperationKind::MoveCommitFile));
|
||||
super::move_commit_file(
|
||||
project_repository,
|
||||
branch_id,
|
||||
@ -721,7 +717,7 @@ impl ControllerInner {
|
||||
self.with_verify_branch(project_id, |project_repository, _| {
|
||||
let _ = project_repository
|
||||
.project()
|
||||
.snapshot_commit_undo(commit_oid.to_string());
|
||||
.snapshot_commit_undo(commit_oid);
|
||||
super::undo_commit(project_repository, branch_id, commit_oid).map_err(Into::into)
|
||||
})
|
||||
}
|
||||
@ -738,7 +734,7 @@ impl ControllerInner {
|
||||
self.with_verify_branch(project_id, |project_repository, user| {
|
||||
let _ = project_repository
|
||||
.project()
|
||||
.create_snapshot(SnapshotDetails::new(OperationType::InsertBlankCommit));
|
||||
.create_snapshot(SnapshotDetails::new(OperationKind::InsertBlankCommit));
|
||||
super::insert_blank_commit(project_repository, branch_id, commit_oid, user, offset)
|
||||
.map_err(Into::into)
|
||||
})
|
||||
@ -756,7 +752,7 @@ impl ControllerInner {
|
||||
self.with_verify_branch(project_id, |project_repository, _| {
|
||||
let _ = project_repository
|
||||
.project()
|
||||
.create_snapshot(SnapshotDetails::new(OperationType::ReorderCommit));
|
||||
.create_snapshot(SnapshotDetails::new(OperationKind::ReorderCommit));
|
||||
super::reorder_commit(project_repository, branch_id, commit_oid, offset)
|
||||
.map_err(Into::into)
|
||||
})
|
||||
@ -773,7 +769,7 @@ impl ControllerInner {
|
||||
self.with_verify_branch(project_id, |project_repository, _| {
|
||||
let _ = project_repository
|
||||
.project()
|
||||
.create_snapshot(SnapshotDetails::new(OperationType::UndoCommit));
|
||||
.create_snapshot(SnapshotDetails::new(OperationKind::UndoCommit));
|
||||
super::reset_branch(project_repository, branch_id, target_commit_oid)
|
||||
.map_err(Into::into)
|
||||
})
|
||||
@ -827,7 +823,7 @@ impl ControllerInner {
|
||||
self.with_verify_branch(project_id, |project_repository, _| {
|
||||
let _ = project_repository
|
||||
.project()
|
||||
.create_snapshot(SnapshotDetails::new(OperationType::CherryPick));
|
||||
.create_snapshot(SnapshotDetails::new(OperationKind::CherryPick));
|
||||
super::cherry_pick(project_repository, branch_id, commit_oid).map_err(Into::into)
|
||||
})
|
||||
}
|
||||
@ -862,7 +858,7 @@ impl ControllerInner {
|
||||
self.with_verify_branch(project_id, |project_repository, _| {
|
||||
let _ = project_repository
|
||||
.project()
|
||||
.create_snapshot(SnapshotDetails::new(OperationType::SquashCommit));
|
||||
.create_snapshot(SnapshotDetails::new(OperationKind::SquashCommit));
|
||||
super::squash(project_repository, branch_id, commit_oid).map_err(Into::into)
|
||||
})
|
||||
}
|
||||
@ -878,7 +874,7 @@ impl ControllerInner {
|
||||
self.with_verify_branch(project_id, |project_repository, _| {
|
||||
let _ = project_repository
|
||||
.project()
|
||||
.create_snapshot(SnapshotDetails::new(OperationType::UpdateCommitMessage));
|
||||
.create_snapshot(SnapshotDetails::new(OperationKind::UpdateCommitMessage));
|
||||
super::update_commit_message(project_repository, branch_id, commit_oid, message)
|
||||
.map_err(Into::into)
|
||||
})
|
||||
@ -958,7 +954,7 @@ impl ControllerInner {
|
||||
self.with_verify_branch(project_id, |project_repository, user| {
|
||||
let _ = project_repository
|
||||
.project()
|
||||
.create_snapshot(SnapshotDetails::new(OperationType::MoveCommit));
|
||||
.create_snapshot(SnapshotDetails::new(OperationKind::MoveCommit));
|
||||
super::move_commit(project_repository, target_branch_id, commit_oid, user)
|
||||
.map_err(Into::into)
|
||||
})
|
||||
|
@ -17,8 +17,8 @@ lazy_static! {
|
||||
}
|
||||
|
||||
const WORKSPACE_HEAD: &str = "Workspace Head";
|
||||
const GITBUTLER_INTEGRATION_COMMIT_AUTHOR_NAME: &str = "GitButler";
|
||||
const GITBUTLER_INTEGRATION_COMMIT_AUTHOR_EMAIL: &str = "gitbutler@gitbutler.com";
|
||||
pub const GITBUTLER_INTEGRATION_COMMIT_AUTHOR_NAME: &str = "GitButler";
|
||||
pub const GITBUTLER_INTEGRATION_COMMIT_AUTHOR_EMAIL: &str = "gitbutler@gitbutler.com";
|
||||
|
||||
fn get_committer<'a>() -> Result<git::Signature<'a>> {
|
||||
Ok(git::Signature::now(
|
||||
|
@ -1,10 +1,9 @@
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
fs::File,
|
||||
io::Read,
|
||||
path::{Path, PathBuf},
|
||||
};
|
||||
|
||||
use crate::fs::read_toml_file_or_default;
|
||||
use anyhow::anyhow;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
@ -118,19 +117,7 @@ impl VirtualBranchesHandle {
|
||||
///
|
||||
/// If the file does not exist, it will be created.
|
||||
fn read_file(&self) -> Result<VirtualBranches, crate::reader::Error> {
|
||||
// let file_path = &self.file_path.lock().await;
|
||||
if !self.file_path.exists() {
|
||||
return Ok(VirtualBranches::default());
|
||||
}
|
||||
let mut file: File = File::open(self.file_path.as_path())?;
|
||||
let mut contents = String::new();
|
||||
file.read_to_string(&mut contents)?;
|
||||
let virtual_branches: VirtualBranches =
|
||||
toml::from_str(&contents).map_err(|e| crate::reader::Error::ParseError {
|
||||
path: self.file_path.clone(),
|
||||
source: e,
|
||||
})?;
|
||||
Ok(virtual_branches)
|
||||
read_toml_file_or_default(&self.file_path)
|
||||
}
|
||||
|
||||
fn write_file(&self, virtual_branches: &VirtualBranches) -> anyhow::Result<()> {
|
||||
|
@ -26,7 +26,6 @@ use super::{
|
||||
};
|
||||
use crate::error::{self, AnyhowContextExt, Code};
|
||||
use crate::git::diff::{diff_files_into_hunks, trees, FileDiff};
|
||||
use crate::ops::snapshot::Snapshot;
|
||||
use crate::time::now_since_unix_epoch_ms;
|
||||
use crate::virtual_branches::branch::HunkHash;
|
||||
use crate::{
|
||||
|
@ -7,6 +7,7 @@ mod error;
|
||||
mod git;
|
||||
mod keys;
|
||||
mod lock;
|
||||
mod ops;
|
||||
mod reader;
|
||||
mod types;
|
||||
pub mod virtual_branches;
|
165
crates/gitbutler-core/tests/ops/entry.rs
Normal file
165
crates/gitbutler-core/tests/ops/entry.rs
Normal file
@ -0,0 +1,165 @@
|
||||
mod trailer {
|
||||
use gitbutler_core::ops::entry::Trailer;
|
||||
use std::str::FromStr;
|
||||
|
||||
#[test]
|
||||
fn display() {
|
||||
let trailer = Trailer {
|
||||
key: "foo".to_string(),
|
||||
value: "bar".to_string(),
|
||||
};
|
||||
assert_eq!(format!("{}", trailer), "foo: bar");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn from_str() {
|
||||
let s = "foo: bar";
|
||||
let trailer = Trailer::from_str(s).unwrap();
|
||||
assert_eq!(trailer.key, "foo");
|
||||
assert_eq!(trailer.value, "bar");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn from_str_invalid() {
|
||||
let s = "foobar";
|
||||
let result = Trailer::from_str(s);
|
||||
assert!(result.is_err());
|
||||
}
|
||||
}
|
||||
|
||||
mod version {
|
||||
use gitbutler_core::ops::entry::{Trailer, Version};
|
||||
use std::str::FromStr;
|
||||
|
||||
#[test]
|
||||
fn from_trailer() {
|
||||
let s = "Version: 1";
|
||||
let trailer = Trailer::from_str(s).unwrap();
|
||||
let version = Version::from_str(&trailer.value).unwrap();
|
||||
assert_eq!(version, Version::default());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid() {
|
||||
let s = "Version: -1";
|
||||
let trailer = Trailer::from_str(s).unwrap();
|
||||
let version = Version::from_str(&trailer.value);
|
||||
assert!(version.is_err());
|
||||
}
|
||||
}
|
||||
|
||||
mod operation_kind {
|
||||
use gitbutler_core::ops::entry::{OperationKind, SnapshotDetails, Trailer, Version};
|
||||
use std::str::FromStr;
|
||||
|
||||
#[test]
|
||||
fn from_trailer() {
|
||||
let s = "Operation: CreateCommit";
|
||||
let trailer = Trailer::from_str(s).unwrap();
|
||||
let operation = OperationKind::from_str(&trailer.value).unwrap();
|
||||
assert_eq!(operation, OperationKind::CreateCommit);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown() {
|
||||
let commit_message = "Create a new snapshot\n\nBody text 1\nBody text2\n\nBody text 3\n\nVersion: 1\nOperation: Asdf\nFoo: Bar\n";
|
||||
let details = SnapshotDetails::from_str(commit_message).unwrap();
|
||||
assert_eq!(details.version, Version::default());
|
||||
assert_eq!(details.operation, OperationKind::Unknown);
|
||||
assert_eq!(details.title, "Create a new snapshot");
|
||||
assert_eq!(
|
||||
details.body,
|
||||
Some("Body text 1\nBody text2\n\nBody text 3".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
details.trailers,
|
||||
vec![Trailer {
|
||||
key: "Foo".to_string(),
|
||||
value: "Bar".to_string(),
|
||||
}]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
mod snapshot_details {
|
||||
use gitbutler_core::ops::entry::{OperationKind, Snapshot, SnapshotDetails, Trailer, Version};
|
||||
use std::path::PathBuf;
|
||||
use std::str::FromStr;
|
||||
|
||||
#[test]
|
||||
fn new() {
|
||||
let commit_sha = git2::Oid::zero().into();
|
||||
let commit_message =
|
||||
"Create a new snapshot\n\nBody text 1\nBody text2\n\nBody text 3\n\nVersion: 1\nOperation: CreateCommit\nFoo: Bar\n".to_string();
|
||||
let timezone_offset_does_not_matter = 1234;
|
||||
let created_at = git2::Time::new(1234567890, timezone_offset_does_not_matter);
|
||||
let details = SnapshotDetails::from_str(&commit_message.clone()).unwrap();
|
||||
let snapshot = Snapshot {
|
||||
commit_id: commit_sha,
|
||||
created_at,
|
||||
lines_added: 1,
|
||||
lines_removed: 1,
|
||||
files_changed: vec![PathBuf::from("foo.txt")],
|
||||
details: Some(details),
|
||||
};
|
||||
assert_eq!(snapshot.commit_id, commit_sha);
|
||||
assert_eq!(snapshot.created_at, created_at);
|
||||
let details = snapshot.details.unwrap();
|
||||
assert_eq!(details.version, Version::default());
|
||||
assert_eq!(details.operation, OperationKind::CreateCommit);
|
||||
assert_eq!(details.title, "Create a new snapshot");
|
||||
assert_eq!(
|
||||
details.body,
|
||||
Some("Body text 1\nBody text2\n\nBody text 3".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
details.trailers,
|
||||
vec![Trailer {
|
||||
key: "Foo".to_string(),
|
||||
value: "Bar".to_string(),
|
||||
}]
|
||||
);
|
||||
assert_eq!(details.to_string(), commit_message);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_with_newline_in_trailer() {
|
||||
let snapshot_details = new_details(Trailer {
|
||||
key: "Message".to_string(),
|
||||
value: "Header\n\nBody".to_string(),
|
||||
});
|
||||
let serialized = snapshot_details.to_string();
|
||||
let deserialized = SnapshotDetails::from_str(&serialized).unwrap();
|
||||
assert_eq!(
|
||||
deserialized, snapshot_details,
|
||||
"this works because newlines are quoted"
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_with_space_in_trailer_key() {
|
||||
for value in ["trailing-space ", " leading-space"] {
|
||||
let trailer = Trailer {
|
||||
key: value.to_string(),
|
||||
value: "anything".to_string(),
|
||||
};
|
||||
let mut snapshot_details = new_details(trailer);
|
||||
let trailer = &mut snapshot_details.trailers[0];
|
||||
trailer.key = trailer.key.trim().to_string();
|
||||
|
||||
let serialized = snapshot_details.to_string();
|
||||
let deserialized = SnapshotDetails::from_str(&serialized).unwrap();
|
||||
assert_eq!(deserialized, snapshot_details, "values are trimmed")
|
||||
}
|
||||
}
|
||||
|
||||
fn new_details(trailer: Trailer) -> SnapshotDetails {
|
||||
SnapshotDetails {
|
||||
version: Version::default(),
|
||||
operation: OperationKind::CreateCommit,
|
||||
title: "Create a new snapshot".to_string(),
|
||||
body: None,
|
||||
trailers: vec![trailer],
|
||||
}
|
||||
}
|
||||
}
|
1
crates/gitbutler-core/tests/ops/mod.rs
Normal file
1
crates/gitbutler-core/tests/ops/mod.rs
Normal file
@ -0,0 +1 @@
|
||||
mod entry;
|
@ -1,11 +1,10 @@
|
||||
use std::io::Write;
|
||||
|
||||
use gitbutler_core::ops::oplog::Oplog;
|
||||
|
||||
use super::*;
|
||||
use itertools::Itertools;
|
||||
use std::io::Write;
|
||||
use std::time::Duration;
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_basic_oplog() {
|
||||
async fn workdir_vbranch_restore() -> anyhow::Result<()> {
|
||||
let Test {
|
||||
repository,
|
||||
project_id,
|
||||
@ -19,73 +18,119 @@ async fn test_basic_oplog() {
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let mut branch_ids = Vec::new();
|
||||
let workdir = repository.path();
|
||||
for round in 0..3 {
|
||||
let branch_id = controller
|
||||
.create_virtual_branch(project_id, &branch::BranchCreateRequest::default())
|
||||
.await?;
|
||||
branch_ids.push(branch_id);
|
||||
fs::write(
|
||||
workdir.join(format!("file{round}.txt")),
|
||||
&make_lines(round * 5),
|
||||
)?;
|
||||
controller
|
||||
.create_commit(
|
||||
project_id,
|
||||
&branch_id,
|
||||
"first commit",
|
||||
None,
|
||||
false, /* run hook */
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
|
||||
assert_eq!(
|
||||
project.list_snapshots(10, None)?.len(),
|
||||
6,
|
||||
"3 vbranches + 3 commits"
|
||||
);
|
||||
|
||||
// TODO(ST): continue here
|
||||
// project.restore_snapshot()
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn make_lines(count: u8) -> Vec<u8> {
|
||||
(0..count).map(|n| n.to_string()).join("\n").into()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn basic_oplog() -> anyhow::Result<()> {
|
||||
let Test {
|
||||
repository,
|
||||
project_id,
|
||||
controller,
|
||||
project,
|
||||
..
|
||||
} = &Test::default();
|
||||
|
||||
controller
|
||||
.set_base_branch(project_id, &"refs/remotes/origin/master".parse()?)
|
||||
.await?;
|
||||
|
||||
let branch_id = controller
|
||||
.create_virtual_branch(project_id, &branch::BranchCreateRequest::default())
|
||||
.await
|
||||
.unwrap();
|
||||
.await?;
|
||||
|
||||
// create commit
|
||||
fs::write(repository.path().join("file.txt"), "content").unwrap();
|
||||
fs::write(repository.path().join("file.txt"), "content")?;
|
||||
let _commit1_id = controller
|
||||
.create_commit(project_id, &branch_id, "commit one", None, false)
|
||||
.await
|
||||
.unwrap();
|
||||
.await?;
|
||||
|
||||
// dont store large files
|
||||
let file_path = repository.path().join("large.txt");
|
||||
// write 33MB of random data in the file
|
||||
let mut file = std::fs::File::create(file_path).unwrap();
|
||||
let mut file = std::fs::File::create(file_path)?;
|
||||
for _ in 0..33 * 1024 {
|
||||
let data = [0u8; 1024];
|
||||
file.write_all(&data).unwrap();
|
||||
file.write_all(&data)?;
|
||||
}
|
||||
|
||||
// create commit with large file
|
||||
fs::write(repository.path().join("file2.txt"), "content2").unwrap();
|
||||
fs::write(repository.path().join("file3.txt"), "content3").unwrap();
|
||||
fs::write(repository.path().join("file2.txt"), "content2")?;
|
||||
fs::write(repository.path().join("file3.txt"), "content3")?;
|
||||
let commit2_id = controller
|
||||
.create_commit(project_id, &branch_id, "commit two", None, false)
|
||||
.await
|
||||
.unwrap();
|
||||
.await?;
|
||||
|
||||
// Create conflict state
|
||||
let conflicts_path = repository.path().join(".git").join("conflicts");
|
||||
std::fs::write(&conflicts_path, "conflict A").unwrap();
|
||||
std::fs::write(&conflicts_path, "conflict A")?;
|
||||
let base_merge_parent_path = repository.path().join(".git").join("base_merge_parent");
|
||||
std::fs::write(&base_merge_parent_path, "parent A").unwrap();
|
||||
std::fs::write(&base_merge_parent_path, "parent A")?;
|
||||
|
||||
// create state with conflict state
|
||||
let _empty_branch_id = controller
|
||||
.create_virtual_branch(project_id, &branch::BranchCreateRequest::default())
|
||||
.await
|
||||
.unwrap();
|
||||
.await?;
|
||||
|
||||
std::fs::remove_file(&base_merge_parent_path).unwrap();
|
||||
std::fs::remove_file(&conflicts_path).unwrap();
|
||||
std::fs::remove_file(&base_merge_parent_path)?;
|
||||
std::fs::remove_file(&conflicts_path)?;
|
||||
|
||||
fs::write(repository.path().join("file4.txt"), "content4").unwrap();
|
||||
fs::write(repository.path().join("file4.txt"), "content4")?;
|
||||
let _commit3_id = controller
|
||||
.create_commit(project_id, &branch_id, "commit three", None, false)
|
||||
.await
|
||||
.unwrap();
|
||||
.await?;
|
||||
|
||||
let branch = controller
|
||||
.list_virtual_branches(project_id)
|
||||
.await
|
||||
.unwrap()
|
||||
.await?
|
||||
.0
|
||||
.into_iter()
|
||||
.find(|b| b.id == branch_id)
|
||||
.unwrap();
|
||||
|
||||
let branches = controller.list_virtual_branches(project_id).await.unwrap();
|
||||
let branches = controller.list_virtual_branches(project_id).await?;
|
||||
assert_eq!(branches.0.len(), 2);
|
||||
|
||||
assert_eq!(branch.commits.len(), 3);
|
||||
assert_eq!(branch.commits[0].files.len(), 1);
|
||||
assert_eq!(branch.commits[1].files.len(), 3);
|
||||
|
||||
let snapshots = project.list_snapshots(10, None).unwrap();
|
||||
let snapshots = project.list_snapshots(10, None)?;
|
||||
|
||||
let ops = snapshots
|
||||
.iter()
|
||||
@ -103,25 +148,25 @@ async fn test_basic_oplog() {
|
||||
]
|
||||
);
|
||||
|
||||
project.restore_snapshot(snapshots[1].clone().id).unwrap();
|
||||
project.restore_snapshot(snapshots[1].clone().commit_id)?;
|
||||
|
||||
// restores the conflict files
|
||||
let file_lines = std::fs::read_to_string(&conflicts_path).unwrap();
|
||||
let file_lines = std::fs::read_to_string(&conflicts_path)?;
|
||||
assert_eq!(file_lines, "conflict A");
|
||||
let file_lines = std::fs::read_to_string(&base_merge_parent_path).unwrap();
|
||||
let file_lines = std::fs::read_to_string(&base_merge_parent_path)?;
|
||||
assert_eq!(file_lines, "parent A");
|
||||
|
||||
assert_eq!(snapshots[1].lines_added, 2);
|
||||
assert_eq!(snapshots[1].lines_removed, 0);
|
||||
|
||||
project.restore_snapshot(snapshots[2].clone().id).unwrap();
|
||||
project.restore_snapshot(snapshots[2].clone().commit_id)?;
|
||||
|
||||
// the restore removed our new branch
|
||||
let branches = controller.list_virtual_branches(project_id).await.unwrap();
|
||||
let branches = controller.list_virtual_branches(project_id).await?;
|
||||
assert_eq!(branches.0.len(), 1);
|
||||
|
||||
// assert that the conflicts file was removed
|
||||
assert!(!&conflicts_path.try_exists().unwrap());
|
||||
assert!(!&conflicts_path.try_exists()?);
|
||||
|
||||
// remove commit2_oid from odb
|
||||
let commit_str = &commit2_id.to_string();
|
||||
@ -134,14 +179,14 @@ async fn test_basic_oplog() {
|
||||
let file_path = file_path.join(&commit_str[2..]);
|
||||
assert!(file_path.exists());
|
||||
// remove file
|
||||
std::fs::remove_file(file_path).unwrap();
|
||||
std::fs::remove_file(file_path)?;
|
||||
|
||||
// try to look up that object
|
||||
let repo = git2::Repository::open(&project.path).unwrap();
|
||||
let repo = git2::Repository::open(&project.path)?;
|
||||
let commit = repo.find_commit(commit2_id.into());
|
||||
assert!(commit.is_err());
|
||||
|
||||
project.restore_snapshot(snapshots[1].clone().id).unwrap();
|
||||
project.restore_snapshot(snapshots[1].clone().commit_id)?;
|
||||
|
||||
// test missing commits are recreated
|
||||
let commit = repo.find_commit(commit2_id.into());
|
||||
@ -151,12 +196,18 @@ async fn test_basic_oplog() {
|
||||
assert!(file_path.exists());
|
||||
|
||||
let file_path = repository.path().join("file.txt");
|
||||
let file_lines = std::fs::read_to_string(file_path).unwrap();
|
||||
let file_lines = std::fs::read_to_string(file_path)?;
|
||||
assert_eq!(file_lines, "content");
|
||||
|
||||
assert!(
|
||||
!project.should_auto_snapshot(Duration::ZERO)?,
|
||||
"not enough lines changed"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_oplog_restores_gitbutler_integration() {
|
||||
async fn restores_gitbutler_integration() -> anyhow::Result<()> {
|
||||
let Test {
|
||||
repository,
|
||||
project_id,
|
||||
@ -166,59 +217,110 @@ async fn test_oplog_restores_gitbutler_integration() {
|
||||
} = &Test::default();
|
||||
|
||||
controller
|
||||
.set_base_branch(project_id, &"refs/remotes/origin/master".parse().unwrap())
|
||||
.await
|
||||
.unwrap();
|
||||
.set_base_branch(project_id, &"refs/remotes/origin/master".parse()?)
|
||||
.await?;
|
||||
|
||||
assert_eq!(project.virtual_branches().list_branches()?.len(), 0);
|
||||
let branch_id = controller
|
||||
.create_virtual_branch(project_id, &branch::BranchCreateRequest::default())
|
||||
.await
|
||||
.unwrap();
|
||||
.await?;
|
||||
assert_eq!(project.virtual_branches().list_branches()?.len(), 1);
|
||||
|
||||
// create commit
|
||||
fs::write(repository.path().join("file.txt"), "content").unwrap();
|
||||
fs::write(repository.path().join("file.txt"), "content")?;
|
||||
let _commit1_id = controller
|
||||
.create_commit(project_id, &branch_id, "commit one", None, false)
|
||||
.await
|
||||
.unwrap();
|
||||
.await?;
|
||||
|
||||
let repo = git2::Repository::open(&project.path).unwrap();
|
||||
let repo = git2::Repository::open(&project.path)?;
|
||||
|
||||
// check the integration commit
|
||||
let head = repo.head();
|
||||
let commit = &head.as_ref().unwrap().peel_to_commit().unwrap();
|
||||
let commit_oid = commit.id();
|
||||
let head = repo.head().expect("never unborn");
|
||||
let commit = &head.peel_to_commit()?;
|
||||
let commit1_id = commit.id();
|
||||
let message = commit.summary().unwrap();
|
||||
assert_eq!(message, "GitButler Integration Commit");
|
||||
|
||||
// create second commit
|
||||
fs::write(repository.path().join("file.txt"), "content").unwrap();
|
||||
fs::write(repository.path().join("file.txt"), "changed content")?;
|
||||
let _commit2_id = controller
|
||||
.create_commit(project_id, &branch_id, "commit one", None, false)
|
||||
.await
|
||||
.unwrap();
|
||||
.create_commit(project_id, &branch_id, "commit two", None, false)
|
||||
.await?;
|
||||
|
||||
// check the integration commit changed
|
||||
let head = repo.head();
|
||||
let commit = &head.as_ref().unwrap().peel_to_commit().unwrap();
|
||||
let commit2_oid = commit.id();
|
||||
let head = repo.head().expect("never unborn");
|
||||
let commit = &head.peel_to_commit()?;
|
||||
let commit2_id = commit.id();
|
||||
let message = commit.summary().unwrap();
|
||||
assert_eq!(message, "GitButler Integration Commit");
|
||||
assert_ne!(commit_oid, commit2_oid);
|
||||
assert_ne!(commit1_id, commit2_id);
|
||||
|
||||
// restore the first
|
||||
let snapshots = project.list_snapshots(10, None).unwrap();
|
||||
project.restore_snapshot(snapshots[0].clone().id).unwrap();
|
||||
let snapshots = project.list_snapshots(10, None)?;
|
||||
assert_eq!(
|
||||
snapshots.len(),
|
||||
3,
|
||||
"one vbranch, two commits, one snapshot each"
|
||||
);
|
||||
project
|
||||
.restore_snapshot(snapshots[0].commit_id)
|
||||
.expect("can restore the most recent snapshot, to undo commit 2, resetting to commit 1");
|
||||
|
||||
let head = repo.head();
|
||||
let commit = &head.as_ref().unwrap().peel_to_commit().unwrap();
|
||||
let commit_restore_oid = commit.id();
|
||||
assert_eq!(commit_oid, commit_restore_oid);
|
||||
let head = repo.head().expect("never unborn");
|
||||
let current_commit = &head.peel_to_commit()?;
|
||||
let id_of_restored_commit = current_commit.id();
|
||||
assert_eq!(
|
||||
commit1_id, id_of_restored_commit,
|
||||
"head now points to the first commit, it's not commit 2 anymore"
|
||||
);
|
||||
|
||||
let vbranches = project.virtual_branches().list_branches()?;
|
||||
assert_eq!(
|
||||
vbranches.len(),
|
||||
1,
|
||||
"vbranches aren't affected by this (only the head commit)"
|
||||
);
|
||||
let all_snapshots = project.list_snapshots(10, None)?;
|
||||
assert_eq!(
|
||||
all_snapshots.len(),
|
||||
4,
|
||||
"the restore is tracked as separate snapshot"
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
project.list_snapshots(0, None)?.len(),
|
||||
0,
|
||||
"it respects even non-sensical limits"
|
||||
);
|
||||
|
||||
let snapshots = project.list_snapshots(1, None)?;
|
||||
assert_eq!(snapshots.len(), 1);
|
||||
assert_eq!(
|
||||
project.list_snapshots(1, Some(snapshots[0].commit_id))?,
|
||||
snapshots,
|
||||
"traversal from oplog head is the same as if it wasn't specified, and the given head is returned first"
|
||||
);
|
||||
assert_eq!(
|
||||
project.list_snapshots(10, Some(all_snapshots[2].commit_id))?,
|
||||
&all_snapshots[2..],
|
||||
);
|
||||
|
||||
let first_snapshot = all_snapshots.last().unwrap();
|
||||
assert_eq!(
|
||||
(
|
||||
first_snapshot.lines_added,
|
||||
first_snapshot.lines_removed,
|
||||
first_snapshot.files_changed.len()
|
||||
),
|
||||
(0, 0, 0),
|
||||
"The first snapshot is intentionally not listing everything as changed"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// test operations-log.toml head is not a commit
|
||||
#[tokio::test]
|
||||
async fn test_oplog_head_corrupt() {
|
||||
async fn head_corrupt_is_recreated_automatically() {
|
||||
let Test {
|
||||
repository,
|
||||
project_id,
|
||||
@ -227,7 +329,6 @@ async fn test_oplog_head_corrupt() {
|
||||
..
|
||||
} = &Test::default();
|
||||
|
||||
// No snapshots can be created before a base branch is set
|
||||
controller
|
||||
.set_base_branch(project_id, &"refs/remotes/origin/master".parse().unwrap())
|
||||
.await
|
||||
@ -238,16 +339,16 @@ async fn test_oplog_head_corrupt() {
|
||||
.unwrap();
|
||||
|
||||
let snapshots = project.list_snapshots(10, None).unwrap();
|
||||
assert_eq!(snapshots.len(), 1);
|
||||
assert_eq!(
|
||||
snapshots.len(),
|
||||
1,
|
||||
"No snapshots can be created before a base branch is set, hence only 1 snapshot despite two calls"
|
||||
);
|
||||
|
||||
// overwrite oplog head with a non-commit sha
|
||||
let file_path = repository
|
||||
.path()
|
||||
.join(".git")
|
||||
.join("gitbutler")
|
||||
.join("operations-log.toml");
|
||||
let oplog_path = repository.path().join(".git/gitbutler/operations-log.toml");
|
||||
fs::write(
|
||||
file_path,
|
||||
oplog_path,
|
||||
"head_sha = \"758d54f587227fba3da3b61fbb54a99c17903d59\"",
|
||||
)
|
||||
.unwrap();
|
||||
@ -255,9 +356,12 @@ async fn test_oplog_head_corrupt() {
|
||||
controller
|
||||
.set_base_branch(project_id, &"refs/remotes/origin/master".parse().unwrap())
|
||||
.await
|
||||
.unwrap();
|
||||
.expect("the snapshot doesn't fail despite the corrupt head");
|
||||
|
||||
// it should have just reset the oplog head, so only 1, not 2
|
||||
let snapshots = project.list_snapshots(10, None).unwrap();
|
||||
assert_eq!(snapshots.len(), 1);
|
||||
assert_eq!(
|
||||
snapshots.len(),
|
||||
1,
|
||||
"it should have just reset the oplog head, so only 1, not 2"
|
||||
);
|
||||
}
|
||||
|
@ -157,7 +157,6 @@ mod go_back_to_integration {
|
||||
controller
|
||||
.set_base_branch(project_id, &"refs/remotes/origin/master".parse().unwrap())
|
||||
.await
|
||||
.map_err(|error| dbg!(error))
|
||||
.unwrap_err()
|
||||
.downcast_ref(),
|
||||
Some(errors::SetBaseBranchError::DirtyWorkingDirectory)
|
||||
|
@ -2,7 +2,7 @@ use crate::error::Error;
|
||||
use anyhow::Context;
|
||||
use gitbutler_core::git::diff::FileDiff;
|
||||
use gitbutler_core::{
|
||||
ops::{entry::Snapshot, oplog::Oplog},
|
||||
ops::entry::Snapshot,
|
||||
projects::{self, ProjectId},
|
||||
};
|
||||
use std::collections::HashMap;
|
||||
@ -22,7 +22,11 @@ pub async fn list_snapshots(
|
||||
.state::<projects::Controller>()
|
||||
.get(&project_id)
|
||||
.context("failed to get project")?;
|
||||
let snapshots = project.list_snapshots(limit, sha)?;
|
||||
let snapshots = project.list_snapshots(
|
||||
limit,
|
||||
sha.map(|hex| hex.parse().map_err(anyhow::Error::from))
|
||||
.transpose()?,
|
||||
)?;
|
||||
Ok(snapshots)
|
||||
}
|
||||
|
||||
@ -37,7 +41,7 @@ pub async fn restore_snapshot(
|
||||
.state::<projects::Controller>()
|
||||
.get(&project_id)
|
||||
.context("failed to get project")?;
|
||||
project.restore_snapshot(sha)?;
|
||||
project.restore_snapshot(sha.parse().map_err(anyhow::Error::from)?)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@ -52,6 +56,6 @@ pub async fn snapshot_diff(
|
||||
.state::<projects::Controller>()
|
||||
.get(&project_id)
|
||||
.context("failed to get project")?;
|
||||
let diff = project.snapshot_diff(sha)?;
|
||||
let diff = project.snapshot_diff(sha.parse().map_err(anyhow::Error::from)?)?;
|
||||
Ok(diff)
|
||||
}
|
||||
|
@ -162,6 +162,11 @@ pub fn test_repository() -> (gitbutler_core::git::Repository, TempDir) {
|
||||
let tmp = temp_dir();
|
||||
let repository = gitbutler_core::git::Repository::init_opts(&tmp, &init_opts())
|
||||
.expect("failed to init repository");
|
||||
repository
|
||||
.config()
|
||||
.unwrap()
|
||||
.set_local("commit.gpgsign", "false")
|
||||
.unwrap();
|
||||
let mut index = repository.index().expect("failed to get index");
|
||||
let oid = index.write_tree().expect("failed to write tree");
|
||||
let signature = gitbutler_core::git::Signature::now("test", "test@email.com").unwrap();
|
||||
|
@ -30,11 +30,7 @@ impl Default for TestProject {
|
||||
let local_tmp = temp_dir();
|
||||
let local_repository = git::Repository::init_opts(local_tmp.path(), &init_opts())
|
||||
.expect("failed to init repository");
|
||||
local_repository
|
||||
.config()
|
||||
.unwrap()
|
||||
.set_local("commit.gpgsign", "false")
|
||||
.unwrap();
|
||||
setup_config(&local_repository.config().unwrap()).unwrap();
|
||||
let mut index = local_repository.index().expect("failed to get index");
|
||||
let oid = index.write_tree().expect("failed to write tree");
|
||||
let signature = git::Signature::now("test", "test@email.com").unwrap();
|
||||
@ -60,11 +56,7 @@ impl Default for TestProject {
|
||||
.external_template(false),
|
||||
)
|
||||
.expect("failed to init repository");
|
||||
remote_repository
|
||||
.config()
|
||||
.unwrap()
|
||||
.set_local("commit.gpgsign", "false")
|
||||
.unwrap();
|
||||
setup_config(&remote_repository.config().unwrap()).unwrap();
|
||||
|
||||
{
|
||||
let mut remote = local_repository
|
||||
@ -357,3 +349,8 @@ impl TestProject {
|
||||
submodule.add_finalize().unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
fn setup_config(config: &git::Config) -> anyhow::Result<()> {
|
||||
config.set_local("commit.gpgsign", "false")?;
|
||||
Ok(())
|
||||
}
|
||||
|
@ -2,8 +2,7 @@ use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use gitbutler_core::ops::entry::{OperationType, SnapshotDetails};
|
||||
use gitbutler_core::ops::oplog::Oplog;
|
||||
use gitbutler_core::ops::entry::{OperationKind, SnapshotDetails};
|
||||
use gitbutler_core::projects::ProjectId;
|
||||
use gitbutler_core::synchronize::sync_with_gitbutler;
|
||||
use gitbutler_core::virtual_branches::VirtualBranches;
|
||||
@ -126,8 +125,11 @@ impl Handler {
|
||||
.projects
|
||||
.get(&project_id)
|
||||
.context("failed to get project")?;
|
||||
if project.should_auto_snapshot().unwrap_or_default() {
|
||||
project.create_snapshot(SnapshotDetails::new(OperationType::FileChanges))?;
|
||||
if project
|
||||
.should_auto_snapshot(std::time::Duration::from_secs(300))
|
||||
.unwrap_or_default()
|
||||
{
|
||||
project.create_snapshot(SnapshotDetails::new(OperationKind::FileChanges))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user