diff --git a/crates/gitbutler-core/src/lib.rs b/crates/gitbutler-core/src/lib.rs index 0c5c5202b..ce6984a4b 100644 --- a/crates/gitbutler-core/src/lib.rs +++ b/crates/gitbutler-core/src/lib.rs @@ -29,6 +29,7 @@ pub mod path; pub mod project_repository; pub mod projects; pub mod reader; +pub mod repo; pub mod sessions; pub mod snapshots; pub mod ssh; diff --git a/crates/gitbutler-core/src/projects/project.rs b/crates/gitbutler-core/src/projects/project.rs index f085dc5a0..9bd941ad9 100644 --- a/crates/gitbutler-core/src/projects/project.rs +++ b/crates/gitbutler-core/src/projects/project.rs @@ -84,6 +84,13 @@ pub struct Project { pub omit_certificate_check: Option, #[serde(default)] pub enable_snapshots: Option, + // The number of changed lines that will trigger a snapshot + #[serde(default = "default_snapshot_lines_threshold")] + pub snapshot_lines_threshold: usize, +} + +fn default_snapshot_lines_threshold() -> usize { + 20 } impl AsRef for Project { diff --git a/crates/gitbutler-core/src/repo.rs b/crates/gitbutler-core/src/repo.rs new file mode 100644 index 000000000..30b9c055b --- /dev/null +++ b/crates/gitbutler-core/src/repo.rs @@ -0,0 +1,18 @@ +use anyhow::Result; + +/// The GbRepository trait provides methods which are building blocks for Gitbutler functionality. +pub trait GbRepository { + /// Returns the number of uncommitted lines of code (added plus removed) in the repository. Included untracked files. + fn changed_lines_count(&self) -> Result; +} + +impl GbRepository for git2::Repository { + fn changed_lines_count(&self) -> Result { + let head_tree = self.head()?.peel_to_commit()?.tree()?; + let mut opts = git2::DiffOptions::new(); + opts.include_untracked(true); + let diff = self.diff_tree_to_workdir_with_index(Some(&head_tree), Some(&mut opts)); + let stats = diff?.stats()?; + Ok(stats.deletions() + stats.insertions()) + } +} diff --git a/crates/gitbutler-core/src/snapshots/entry.rs b/crates/gitbutler-core/src/snapshots/entry.rs index 1c415e62f..d5e5bb4f4 100644 --- a/crates/gitbutler-core/src/snapshots/entry.rs +++ b/crates/gitbutler-core/src/snapshots/entry.rs @@ -152,6 +152,7 @@ pub enum OperationType { ReorderCommit, InsertBlankCommit, MoveCommitFile, + FileChanges, #[default] Unknown, } diff --git a/crates/gitbutler-watcher/Cargo.toml b/crates/gitbutler-watcher/Cargo.toml index 68297fc17..8a95e0cd6 100644 --- a/crates/gitbutler-watcher/Cargo.toml +++ b/crates/gitbutler-watcher/Cargo.toml @@ -15,6 +15,7 @@ futures = "0.3.30" tokio = { workspace = true, features = [ "macros" ] } tokio-util = "0.7.10" tracing = "0.1.40" +git2.workspace = true backoff = "0.4.0" notify = { version = "6.0.1" } diff --git a/crates/gitbutler-watcher/src/handler/mod.rs b/crates/gitbutler-watcher/src/handler/mod.rs index 41cd34c44..fb928d806 100644 --- a/crates/gitbutler-watcher/src/handler/mod.rs +++ b/crates/gitbutler-watcher/src/handler/mod.rs @@ -8,12 +8,16 @@ use std::{path, time}; use anyhow::{bail, Context, Result}; use gitbutler_core::projects::ProjectId; +use gitbutler_core::repo::GbRepository; use gitbutler_core::sessions::SessionId; +use gitbutler_core::snapshots::entry::{OperationType, SnapshotDetails, Trailer}; +use gitbutler_core::snapshots::snapshot; use gitbutler_core::virtual_branches::VirtualBranches; use gitbutler_core::{ assets, deltas, gb_repository, git, project_repository, projects, reader, sessions, users, virtual_branches, }; +use tokio::sync::Mutex; use tracing::instrument; use super::{events, Change}; @@ -39,6 +43,9 @@ pub struct Handler { /// A function to send events - decoupled from app-handle for testing purposes. #[allow(clippy::type_complexity)] send_event: Arc Result<()> + Send + Sync + 'static>, + + // The number of changed lines since the value was last reset + changed_lines_count: Arc>, } impl Handler { @@ -63,6 +70,7 @@ impl Handler { sessions_db, deltas_db, send_event: Arc::new(send_event), + changed_lines_count: Arc::new(Mutex::new(0)), } } @@ -278,15 +286,57 @@ impl Handler { paths: Vec, project_id: ProjectId, ) -> Result<()> { + let paths_string = paths + .iter() + .map(|p| p.to_string_lossy().to_string()) + .collect::>() + .join(","); let calc_deltas = tokio::task::spawn_blocking({ let this = self.clone(); move || this.calculate_deltas(paths, project_id) }); + let changed_lines_before = *self.changed_lines_count.lock().await; + // Create a snapshot every time there are more than 20 new lines of code + let handle_snapshots = tokio::task::spawn_blocking({ + let this = self.clone(); + move || this.maybe_create_snapshot(project_id, changed_lines_before, paths_string) + }); + // Set changed lines count to the newly observed value + *self.changed_lines_count.lock().await = handle_snapshots.await??; self.calculate_virtual_branches(project_id).await?; calc_deltas.await??; Ok(()) } + fn maybe_create_snapshot( + &self, + project_id: ProjectId, + changed_lines_before: usize, + paths: String, + ) -> anyhow::Result { + let project = self + .projects + .get(&project_id) + .context("failed to get project")?; + let repo_path = project.path.as_path(); + let repo = git2::Repository::init(repo_path)?; + let changed_lines = repo.changed_lines_count()? - changed_lines_before; + if changed_lines > project.snapshot_lines_threshold { + let details = SnapshotDetails { + version: Default::default(), + operation: OperationType::FileChanges, + title: OperationType::FileChanges.to_string(), + body: None, + trailers: vec![Trailer { + key: "files".to_string(), + value: paths, + }], + }; + snapshot::create(&project, details)?; + } + Ok(changed_lines) + } + pub async fn git_file_change( &self, path: impl Into,