diff --git a/Cargo.lock b/Cargo.lock index 4b37a4de8..b48273e8d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2275,8 +2275,13 @@ dependencies = [ "anyhow", "git2", "gitbutler-command-context", + "gitbutler-fs", + "gitbutler-reference", + "gitbutler-serde", "gitbutler-testsupport", "serde", + "toml 0.8.15", + "tracing", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 14351c97c..fbd913b2a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -35,7 +35,8 @@ resolver = "2" [workspace.dependencies] bstr = "1.10.0" # Add the `tracing` or `tracing-detail` features to see more of gitoxide in the logs. Useful to see which programs it invokes. -gix = { git = "https://github.com/Byron/gitoxide", rev = "d51f330e9d364c6f7b068116b59bf5c0160e47af", default-features = false, features = [] } +gix = { git = "https://github.com/Byron/gitoxide", rev = "d51f330e9d364c6f7b068116b59bf5c0160e47af", default-features = false, features = [ +] } git2 = { version = "0.18.3", features = [ "vendored-openssl", "vendored-libgit2", @@ -49,6 +50,7 @@ anyhow = "1.0.86" fslock = "0.2.1" parking_lot = "0.12.3" futures = "0.3.30" +toml = "0.8.13" gitbutler-id = { path = "crates/gitbutler-id" } gitbutler-git = { path = "crates/gitbutler-git" } diff --git a/crates/gitbutler-branch/Cargo.toml b/crates/gitbutler-branch/Cargo.toml index 76f46e935..8765246d3 100644 --- a/crates/gitbutler-branch/Cargo.toml +++ b/crates/gitbutler-branch/Cargo.toml @@ -16,7 +16,7 @@ gitbutler-error.workspace = true gitbutler-fs.workspace = true gitbutler-diff.workspace = true itertools = "0.13" -toml = "0.8.15" +toml.workspace = true serde = { workspace = true, features = ["std"] } bstr.workspace = true md5 = "0.7.0" diff --git a/crates/gitbutler-fs/Cargo.toml b/crates/gitbutler-fs/Cargo.toml index 3ce2f763f..757f33d31 100644 --- a/crates/gitbutler-fs/Cargo.toml +++ b/crates/gitbutler-fs/Cargo.toml @@ -6,9 +6,9 @@ authors = ["GitButler "] publish = false [dependencies] -serde = { workspace = true, features = ["std"]} +serde = { workspace = true, features = ["std"] } bstr.workspace = true anyhow = "1.0.86" gix = { workspace = true, features = ["dirwalk", "credentials", "parallel"] } walkdir = "2.5.0" -toml = "0.8.15" +toml.workspace = true diff --git a/crates/gitbutler-operating-modes/Cargo.toml b/crates/gitbutler-operating-modes/Cargo.toml index ad68bc2fc..54cdfebc8 100644 --- a/crates/gitbutler-operating-modes/Cargo.toml +++ b/crates/gitbutler-operating-modes/Cargo.toml @@ -7,9 +7,14 @@ publish = false [dependencies] serde = { workspace = true, features = ["std"] } +tracing = "0.1.40" git2.workspace = true anyhow.workspace = true +toml.workspace = true gitbutler-command-context.workspace = true +gitbutler-serde.workspace = true +gitbutler-fs.workspace = true +gitbutler-reference.workspace = true [dev-dependencies] gitbutler-testsupport.workspace = true diff --git a/crates/gitbutler-operating-modes/src/lib.rs b/crates/gitbutler-operating-modes/src/lib.rs index 618c5b124..980c505d8 100644 --- a/crates/gitbutler-operating-modes/src/lib.rs +++ b/crates/gitbutler-operating-modes/src/lib.rs @@ -1,38 +1,123 @@ +use std::{fs, path::PathBuf}; + use anyhow::{bail, Context, Result}; use gitbutler_command_context::CommandContext; +use gitbutler_reference::ReferenceName; +use serde::{Deserialize, Serialize}; -/// Operating Modes: -/// Gitbutler currently has two main operating modes: -/// - `in workspace mode`: When the app is on the gitbutler/integration branch. -/// This is when normal operations can be performed. -/// - `outside workspace mode`: When the user has left the gitbutler/integration -/// branch to perform regular git commands. +/// The reference the app will checkout when the workspace is open +pub const INTEGRATION_BRANCH_REF: &str = "refs/heads/gitbutler/integration"; +/// The reference the app will checkout when in edit mode +pub const EDIT_BRANCH_REF: &str = "refs/heads/gitbutler/edit"; -const INTEGRATION_BRANCH_REF: &str = "refs/heads/gitbutler/integration"; +fn edit_mode_metadata_path(ctx: &CommandContext) -> PathBuf { + ctx.project().gb_dir().join("edit_mode_metadata.toml") +} -pub fn in_open_workspace_mode(ctx: &CommandContext) -> Result { - let head_ref = ctx.repository().head().context("failed to get head")?; - let head_ref_name = head_ref.name().context("failed to get head name")?; +#[doc(hidden)] +pub fn read_edit_mode_metadata(ctx: &CommandContext) -> Result { + let edit_mode_metadata = fs::read_to_string(edit_mode_metadata_path(ctx).as_path()) + .context("Failed to read edit mode metadata")?; - Ok(head_ref_name == INTEGRATION_BRANCH_REF) + toml::from_str(&edit_mode_metadata).context("Failed to parse edit mode metadata") +} + +#[doc(hidden)] +pub fn write_edit_mode_metadata( + ctx: &CommandContext, + edit_mode_metadata: &EditModeMetadata, +) -> Result<()> { + let serialized_edit_mode_metadata = + toml::to_string(edit_mode_metadata).context("Failed to serialize edit mode metadata")?; + gitbutler_fs::write( + edit_mode_metadata_path(ctx).as_path(), + serialized_edit_mode_metadata, + ) + .context("Failed to write edit mode metadata")?; + + Ok(()) +} + +/// Holds relevant state required to switch to and from edit mode +#[derive(Debug, Serialize, Deserialize, PartialEq)] +pub struct EditModeMetadata { + /// The sha of the commit getting edited. + #[serde(with = "gitbutler_serde::oid")] + pub editee_commit_sha: git2::Oid, + /// The ref of the vbranch which owns this commit. + pub editee_branch: ReferenceName, +} + +#[derive(PartialEq)] +pub enum OperatingMode { + /// The typical app state when its on the gitbutler/integration branch + OpenWorkspace, + /// When the user has chosen to leave the gitbutler/integration branch + OutsideWorkspace, + /// When the app is off of gitbutler/integration and in edit mode + Edit(EditModeMetadata), +} + +pub fn operating_mode(ctx: &CommandContext) -> OperatingMode { + let Ok(head_ref) = ctx.repository().head() else { + return OperatingMode::OutsideWorkspace; + }; + + let Some(head_ref_name) = head_ref.name() else { + return OperatingMode::OutsideWorkspace; + }; + + if head_ref_name == INTEGRATION_BRANCH_REF { + OperatingMode::OpenWorkspace + } else if head_ref_name == EDIT_BRANCH_REF { + let edit_mode_metadata = read_edit_mode_metadata(ctx); + + match edit_mode_metadata { + Ok(edit_mode_metadata) => OperatingMode::Edit(edit_mode_metadata), + Err(error) => { + tracing::warn!( + "Failed to open in edit mode, falling back to outside workspace {}", + error + ); + OperatingMode::OutsideWorkspace + } + } + } else { + OperatingMode::OutsideWorkspace + } +} + +pub fn in_open_workspace_mode(ctx: &CommandContext) -> bool { + operating_mode(ctx) == OperatingMode::OpenWorkspace } pub fn assure_open_workspace_mode(ctx: &CommandContext) -> Result<()> { - if in_open_workspace_mode(ctx)? { + if in_open_workspace_mode(ctx) { Ok(()) } else { - bail!("Unexpected state: cannot perform operation on non-integration branch") + bail!("Expected to be in open workspace mode") } } -pub fn in_outside_workspace_mode(ctx: &CommandContext) -> Result { - in_open_workspace_mode(ctx).map(|open_mode| !open_mode) +pub fn in_edit_mode(ctx: &CommandContext) -> bool { + matches!(operating_mode(ctx), OperatingMode::Edit(_)) +} + +pub fn assure_edit_mode(ctx: &CommandContext) -> Result { + match operating_mode(ctx) { + OperatingMode::Edit(edit_mode_metadata) => Ok(edit_mode_metadata), + _ => bail!("Expected to be in edit mode"), + } +} + +pub fn in_outside_workspace_mode(ctx: &CommandContext) -> bool { + operating_mode(ctx) == OperatingMode::OutsideWorkspace } pub fn assure_outside_workspace_mode(ctx: &CommandContext) -> Result<()> { - if in_outside_workspace_mode(ctx)? { + if in_outside_workspace_mode(ctx) { Ok(()) } else { - bail!("Unexpected state: cannot perform operation on integration branch") + bail!("Expected to be in outside workspace mode") } } diff --git a/crates/gitbutler-operating-modes/tests/operating_modes.rs b/crates/gitbutler-operating-modes/tests/operating_modes.rs index 9fbe21615..cd0540d9c 100644 --- a/crates/gitbutler-operating-modes/tests/operating_modes.rs +++ b/crates/gitbutler-operating-modes/tests/operating_modes.rs @@ -1,4 +1,5 @@ use gitbutler_command_context::CommandContext; +use gitbutler_operating_modes::{write_edit_mode_metadata, EditModeMetadata}; /// Creates a branch from the head commit fn create_and_checkout_branch(ctx: &CommandContext, branch_name: &str) { @@ -16,6 +17,17 @@ fn create_and_checkout_branch(ctx: &CommandContext, branch_name: &str) { .unwrap(); } +fn create_edit_mode_metadata(ctx: &CommandContext) { + write_edit_mode_metadata( + ctx, + &EditModeMetadata { + editee_branch: "asdf".into(), + editee_commit_sha: git2::Oid::zero(), + }, + ) + .unwrap(); +} + mod operating_modes { mod open_workspace_mode { use gitbutler_operating_modes::{assure_open_workspace_mode, in_open_workspace_mode}; @@ -30,10 +42,21 @@ mod operating_modes { create_and_checkout_branch(ctx, "gitbutler/integration"); - let in_open_workspace = in_open_workspace_mode(ctx).unwrap(); + let in_open_workspace = in_open_workspace_mode(ctx); assert!(in_open_workspace); } + #[test] + fn in_open_workspace_mode_false_when_in_gitbutler_edit() { + let suite = Suite::default(); + let Case { ctx, .. } = &suite.new_case(); + + create_and_checkout_branch(ctx, "gitbutler/edit"); + + let in_open_workspace = in_open_workspace_mode(ctx); + assert!(!in_open_workspace); + } + #[test] fn in_open_workspace_mode_false_when_on_other_branches() { let suite = Suite::default(); @@ -41,7 +64,7 @@ mod operating_modes { create_and_checkout_branch(ctx, "testeroni"); - let in_open_workspace = in_open_workspace_mode(ctx).unwrap(); + let in_open_workspace = in_open_workspace_mode(ctx); assert!(!in_open_workspace); } @@ -55,6 +78,16 @@ mod operating_modes { assert!(assure_open_workspace_mode(ctx).is_ok()); } + #[test] + fn assure_open_workspace_mode_err_when_on_gitbutler_edit() { + let suite = Suite::default(); + let Case { ctx, .. } = &suite.new_case(); + + create_and_checkout_branch(ctx, "gitbutler/edit"); + + assert!(assure_open_workspace_mode(ctx).is_err()); + } + #[test] fn assure_open_workspace_mode_err_when_on_other_branch() { let suite = Suite::default(); @@ -70,32 +103,44 @@ mod operating_modes { use gitbutler_operating_modes::{assure_outside_workspace_mode, in_outside_workspace_mode}; use gitbutler_testsupport::{Case, Suite}; - use crate::create_and_checkout_branch; + use crate::{create_and_checkout_branch, create_edit_mode_metadata}; #[test] - fn in_outside_workspace_mode_true_when_in_gitbutler_integration() { + fn in_outside_workspace_mode_true_when_in_other_branches() { let suite = Suite::default(); let Case { ctx, .. } = &suite.new_case(); create_and_checkout_branch(ctx, "testeroni"); - let in_outside_workspace = in_outside_workspace_mode(ctx).unwrap(); + let in_outside_workspace = in_outside_workspace_mode(ctx); assert!(in_outside_workspace); } #[test] - fn in_outside_workspace_mode_false_when_on_other_branches() { + fn in_outside_workspace_mode_false_when_on_gitbutler_edit() { + let suite = Suite::default(); + let Case { ctx, .. } = &suite.new_case(); + + create_and_checkout_branch(ctx, "gitbutler/edit"); + create_edit_mode_metadata(ctx); + + let in_outside_worskpace = in_outside_workspace_mode(ctx); + assert!(!in_outside_worskpace); + } + + #[test] + fn in_outside_workspace_mode_false_when_on_gitbutler_integration() { let suite = Suite::default(); let Case { ctx, .. } = &suite.new_case(); create_and_checkout_branch(ctx, "gitbutler/integration"); - let in_outside_worskpace = in_outside_workspace_mode(ctx).unwrap(); + let in_outside_worskpace = in_outside_workspace_mode(ctx); assert!(!in_outside_worskpace); } #[test] - fn assure_outside_workspace_mode_ok_when_on_gitbutler_integration() { + fn assure_outside_workspace_mode_ok_when_on_other_branches() { let suite = Suite::default(); let Case { ctx, .. } = &suite.new_case(); @@ -105,7 +150,18 @@ mod operating_modes { } #[test] - fn assure_outside_workspace_mode_err_when_on_other_branch() { + fn assure_outside_workspace_mode_err_when_on_gitbutler_edit() { + let suite = Suite::default(); + let Case { ctx, .. } = &suite.new_case(); + + create_and_checkout_branch(ctx, "gitbutler/edit"); + create_edit_mode_metadata(ctx); + + assert!(assure_outside_workspace_mode(ctx).is_err()); + } + + #[test] + fn assure_outside_workspace_mode_err_when_on_gitbutler_integration() { let suite = Suite::default(); let Case { ctx, .. } = &suite.new_case(); @@ -114,4 +170,78 @@ mod operating_modes { assert!(assure_outside_workspace_mode(ctx).is_err()); } } + + mod edit_mode { + use gitbutler_operating_modes::{assure_edit_mode, in_edit_mode}; + use gitbutler_testsupport::{Case, Suite}; + + use crate::{create_and_checkout_branch, create_edit_mode_metadata}; + + #[test] + fn in_edit_mode_true_when_in_edit_mode() { + let suite = Suite::default(); + let Case { ctx, .. } = &suite.new_case(); + + create_and_checkout_branch(ctx, "gitbutler/edit"); + create_edit_mode_metadata(ctx); + + let in_edit_mode = in_edit_mode(ctx); + assert!(in_edit_mode); + } + + #[test] + fn in_edit_mode_false_when_in_edit_mode_with_no_metadata() { + let suite = Suite::default(); + let Case { ctx, .. } = &suite.new_case(); + + create_and_checkout_branch(ctx, "gitbutler/edit"); + + let in_edit_mode = in_edit_mode(ctx); + assert!(!in_edit_mode); + } + + #[test] + fn in_edit_mode_false_when_on_other_branches() { + let suite = Suite::default(); + let Case { ctx, .. } = &suite.new_case(); + + create_and_checkout_branch(ctx, "testeroni"); + create_edit_mode_metadata(ctx); + + let in_edit_mode = in_edit_mode(ctx); + assert!(!in_edit_mode); + } + + #[test] + fn assert_edit_mode_ok_when_in_edit_mode() { + let suite = Suite::default(); + let Case { ctx, .. } = &suite.new_case(); + + create_and_checkout_branch(ctx, "gitbutler/edit"); + create_edit_mode_metadata(ctx); + + assert!(assure_edit_mode(ctx).is_ok()); + } + + #[test] + fn assert_edit_mode_err_when_in_edit_mode_with_no_metadata() { + let suite = Suite::default(); + let Case { ctx, .. } = &suite.new_case(); + + create_and_checkout_branch(ctx, "gitbutler/edit"); + + assert!(assure_edit_mode(ctx).is_err()); + } + + #[test] + fn assert_edit_mode_err_when_on_other_branches() { + let suite = Suite::default(); + let Case { ctx, .. } = &suite.new_case(); + + create_and_checkout_branch(ctx, "testeroni"); + create_edit_mode_metadata(ctx); + + assert!(assure_edit_mode(ctx).is_err()); + } + } } diff --git a/crates/gitbutler-oplog/Cargo.toml b/crates/gitbutler-oplog/Cargo.toml index e865c9055..c613f42a4 100644 --- a/crates/gitbutler-oplog/Cargo.toml +++ b/crates/gitbutler-oplog/Cargo.toml @@ -14,7 +14,7 @@ itertools = "0.13" strum = { version = "0.26", features = ["derive"] } tracing = "0.1.40" gix = { workspace = true, features = ["dirwalk", "credentials", "parallel"] } -toml = "0.8.15" +toml.workspace = true gitbutler-project.workspace = true gitbutler-branch.workspace = true gitbutler-serde.workspace = true diff --git a/crates/gitbutler-watcher/src/handler.rs b/crates/gitbutler-watcher/src/handler.rs index 1a9ce25b6..e6cfd6d9e 100644 --- a/crates/gitbutler-watcher/src/handler.rs +++ b/crates/gitbutler-watcher/src/handler.rs @@ -92,7 +92,7 @@ impl Handler { fn calculate_virtual_branches(&self, project_id: ProjectId) -> Result<()> { let ctx = self.open_command_context(project_id)?; // Skip if we're not on the open workspace mode - if !in_open_workspace_mode(&ctx)? { + if !in_open_workspace_mode(&ctx) { return Ok(()); } @@ -124,7 +124,7 @@ impl Handler { fn recalculate_everything(&self, paths: Vec, project_id: ProjectId) -> Result<()> { let ctx = self.open_command_context(project_id)?; // Skip if we're not on the open workspace mode - if !in_open_workspace_mode(&ctx)? { + if !in_open_workspace_mode(&ctx) { return Ok(()); } @@ -174,7 +174,7 @@ impl Handler { // If the user has left gitbutler/integration, we want to delete the reference. // TODO: why do we want to do this? - if in_outside_workspace_mode(&ctx)? { + if in_outside_workspace_mode(&ctx) { let mut integration_reference = ctx.repository().find_reference( &Refname::from(LocalRefname::new("gitbutler/integration", None)) .to_string(),