From 74c8bce48d54c4f59bce6633cacfddb9ead46aea Mon Sep 17 00:00:00 2001 From: Kiril Videlov Date: Sat, 10 Aug 2024 21:35:40 +0200 Subject: [PATCH 01/16] add BranchReference type to the Branch persisted struct This allows (virtual) branches to keep track of associated references --- crates/gitbutler-branch-actions/src/base.rs | 1 + .../src/branch_manager/branch_creation.rs | 2 ++ crates/gitbutler-branch/src/branch.rs | 4 +++- crates/gitbutler-branch/src/lib.rs | 2 ++ crates/gitbutler-branch/src/reference.rs | 24 +++++++++++++++++++ crates/gitbutler-branch/tests/ownership.rs | 2 ++ 6 files changed, 34 insertions(+), 1 deletion(-) create mode 100644 crates/gitbutler-branch/src/reference.rs diff --git a/crates/gitbutler-branch-actions/src/base.rs b/crates/gitbutler-branch-actions/src/base.rs index 526b7378e7..fce18420d9 100644 --- a/crates/gitbutler-branch-actions/src/base.rs +++ b/crates/gitbutler-branch-actions/src/base.rs @@ -251,6 +251,7 @@ pub(crate) fn set_base_branch( applied: true, in_workspace: true, not_in_workspace_wip_change_id: None, + references: vec![], }; vb_state.set_branch(branch)?; diff --git a/crates/gitbutler-branch-actions/src/branch_manager/branch_creation.rs b/crates/gitbutler-branch-actions/src/branch_manager/branch_creation.rs index bbf226f7c8..ec47113ca0 100644 --- a/crates/gitbutler-branch-actions/src/branch_manager/branch_creation.rs +++ b/crates/gitbutler-branch-actions/src/branch_manager/branch_creation.rs @@ -112,6 +112,7 @@ impl BranchManager<'_> { in_workspace: true, not_in_workspace_wip_change_id: None, source_refname: None, + references: vec![], }; if let Some(ownership) = &create.ownership { @@ -256,6 +257,7 @@ impl BranchManager<'_> { applied: true, in_workspace: true, not_in_workspace_wip_change_id: None, + references: vec![], } }; diff --git a/crates/gitbutler-branch/src/branch.rs b/crates/gitbutler-branch/src/branch.rs index 5a0b5bc431..15cb3a3ba1 100644 --- a/crates/gitbutler-branch/src/branch.rs +++ b/crates/gitbutler-branch/src/branch.rs @@ -5,7 +5,7 @@ use gitbutler_reference::{normalize_branch_name, Refname, RemoteRefname, Virtual use serde::{Deserialize, Serialize, Serializer}; use std::ops::Deref; -use crate::ownership::BranchOwnershipClaims; +use crate::{ownership::BranchOwnershipClaims, reference::BranchReference}; pub type BranchId = Id; @@ -67,6 +67,8 @@ pub struct Branch { pub in_workspace: bool, #[serde(default)] pub not_in_workspace_wip_change_id: Option, + #[serde(default)] + pub references: Vec, } fn default_true() -> bool { diff --git a/crates/gitbutler-branch/src/lib.rs b/crates/gitbutler-branch/src/lib.rs index 773036604d..ec333d4020 100644 --- a/crates/gitbutler-branch/src/lib.rs +++ b/crates/gitbutler-branch/src/lib.rs @@ -16,6 +16,8 @@ pub use ownership::{reconcile_claims, BranchOwnershipClaims, ClaimOutcome}; pub mod serde; mod target; pub use target::Target; +mod reference; +pub use reference::BranchReference; mod state; use lazy_static::lazy_static; diff --git a/crates/gitbutler-branch/src/reference.rs b/crates/gitbutler-branch/src/reference.rs new file mode 100644 index 0000000000..58f089fc16 --- /dev/null +++ b/crates/gitbutler-branch/src/reference.rs @@ -0,0 +1,24 @@ +use gitbutler_reference::ReferenceName; +use serde::{Deserialize, Serialize}; + +use crate::BranchId; + +/// GitButler reference associated with a virtual branch. +/// These are not the same as regular Git references, but rather app-managed refs. +/// Represent a deployable / reviewable part of a virtual branch that can be pushed to a remote +/// and have a "Pull Request" created for it. +// TODO(kv): There is a name collision with `VirtualBranchReference` in `gitbutler-branch-actions/src/branch.rs` where this name means something entirerly different. +#[derive(Serialize, Deserialize, Debug, PartialEq, Clone)] +pub struct BranchReference { + /// Branch id of the virtual branch this reference belongs to + /// Multiple references may belong to the same virtual branch, representing separate deployable / reviewable parts of the vbranch. + pub branch_id: BranchId, + /// Fully qualified reference name. + /// The reference must be a remote reference. + pub upstream: ReferenceName, + /// The commit this reference points to. The commit must be part of the virtual branch. + #[serde(with = "gitbutler_serde::oid")] + pub commit_id: git2::Oid, + /// The change id associated with the commit, if any. + pub change_id: Option, +} diff --git a/crates/gitbutler-branch/tests/ownership.rs b/crates/gitbutler-branch/tests/ownership.rs index 9c29288aeb..2adf54ef6c 100644 --- a/crates/gitbutler-branch/tests/ownership.rs +++ b/crates/gitbutler-branch/tests/ownership.rs @@ -39,6 +39,7 @@ fn reconcile_ownership_simple() { in_workspace: true, not_in_workspace_wip_change_id: None, source_refname: None, + references: vec![], }; let branch_b = Branch { name: "b".to_string(), @@ -67,6 +68,7 @@ fn reconcile_ownership_simple() { in_workspace: true, not_in_workspace_wip_change_id: None, source_refname: None, + references: vec![], }; let all_branches: Vec = vec![branch_a.clone(), branch_b.clone()]; let claim: Vec = vec![OwnershipClaim { From a095f8f28b0d06aeede5270b2bde2ffd71425024 Mon Sep 17 00:00:00 2001 From: Kiril Videlov Date: Wed, 21 Aug 2024 12:21:30 +0200 Subject: [PATCH 02/16] add methods for manipulating branch references fix a thing --- Cargo.lock | 13 ++ Cargo.toml | 2 + crates/gitbutler-stack/Cargo.toml | 16 +++ crates/gitbutler-stack/src/lib.rs | 4 + crates/gitbutler-stack/src/reference.rs | 178 ++++++++++++++++++++++++ 5 files changed, 213 insertions(+) create mode 100644 crates/gitbutler-stack/Cargo.toml create mode 100644 crates/gitbutler-stack/src/lib.rs create mode 100644 crates/gitbutler-stack/src/reference.rs diff --git a/Cargo.lock b/Cargo.lock index 82984ecd1f..be6095512e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2465,6 +2465,19 @@ dependencies = [ "serde", ] +[[package]] +name = "gitbutler-stack" +version = "0.0.0" +dependencies = [ + "anyhow", + "git2", + "gitbutler-branch", + "gitbutler-command-context", + "gitbutler-reference", + "gitbutler-repo", + "itertools 0.13.0", +] + [[package]] name = "gitbutler-storage" version = "0.0.0" diff --git a/Cargo.toml b/Cargo.toml index fafb23d931..d5315eb9d2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -30,6 +30,7 @@ members = [ "crates/gitbutler-diff", "crates/gitbutler-operating-modes", "crates/gitbutler-edit-mode", + "crates/gitbutler-stack", ] resolver = "2" @@ -82,6 +83,7 @@ gitbutler-url = { path = "crates/gitbutler-url" } gitbutler-diff = { path = "crates/gitbutler-diff" } gitbutler-operating-modes = { path = "crates/gitbutler-operating-modes" } gitbutler-edit-mode = { path = "crates/gitbutler-edit-mode" } +gitbutler-stack = { path = "crates/gitbutler-stack" } [profile.release] codegen-units = 1 # Compile crates one after another so the compiler can optimize better diff --git a/crates/gitbutler-stack/Cargo.toml b/crates/gitbutler-stack/Cargo.toml new file mode 100644 index 0000000000..8578aa6c96 --- /dev/null +++ b/crates/gitbutler-stack/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "gitbutler-stack" +version = "0.0.0" +edition = "2021" +authors = ["GitButler "] +publish = false + +[dependencies] +git2.workspace = true +anyhow.workspace = true +gitbutler-command-context.workspace = true +gitbutler-branch.workspace = true +gitbutler-reference.workspace = true +gitbutler-repo.workspace = true +itertools = "0.13" + diff --git a/crates/gitbutler-stack/src/lib.rs b/crates/gitbutler-stack/src/lib.rs new file mode 100644 index 0000000000..d32a62bbf6 --- /dev/null +++ b/crates/gitbutler-stack/src/lib.rs @@ -0,0 +1,4 @@ +mod reference; +pub use reference::{ + create_branch_reference, list_branch_references, push_branch_reference, update_branch_reference, +}; diff --git a/crates/gitbutler-stack/src/reference.rs b/crates/gitbutler-stack/src/reference.rs new file mode 100644 index 0000000000..81c0a397f8 --- /dev/null +++ b/crates/gitbutler-stack/src/reference.rs @@ -0,0 +1,178 @@ +use std::str::FromStr; + +use anyhow::Context; +use anyhow::{anyhow, Result}; +use gitbutler_branch::BranchReference; +use gitbutler_branch::VirtualBranchesHandle; +use gitbutler_branch::{Branch, BranchId}; +use gitbutler_command_context::CommandContext; +use gitbutler_reference::ReferenceName; +use gitbutler_repo::credentials::Helper; +use gitbutler_repo::{LogUntil, RepoActionsExt}; +use itertools::Itertools; + +/// Given a branch id, returns the the GitButler references associated with the branch. +/// References within the same branch effectively represent a stack of sub-branches. +pub fn list_branch_references( + ctx: &CommandContext, + branch_id: BranchId, +) -> Result> { + let handle = VirtualBranchesHandle::new(ctx.project().gb_dir()); + let vbranch = handle.get_branch(branch_id)?; + Ok(vbranch.references) +} + +/// Creates a new virtual branch reference and associates it with the branch. +/// However this will return an error if: +/// - a reference for the same commit already exists, an error is returned. +/// - the reference name already exists, an error is returned. +pub fn create_branch_reference( + ctx: &CommandContext, + branch_id: BranchId, + upstream: ReferenceName, + commit_id: git2::Oid, + change_id: Option, +) -> Result { + // The reference must be parseable as a remote reference + gitbutler_reference::RemoteRefname::from_str(&upstream) + .context("Failed to parse the provided reference")?; + let handle = VirtualBranchesHandle::new(ctx.project().gb_dir()); + + // The branch must exist + let mut vbranch = handle.get_branch(branch_id)?; + let branch_reference = BranchReference { + upstream, + branch_id, + commit_id, + change_id, + }; + let all_references = handle + .list_all_branches()? + .into_iter() + .flat_map(|branch| branch.references) + .collect_vec(); + // Ensure the reference name does not already exist + if all_references + .iter() + .any(|r| r.upstream == branch_reference.upstream) + { + return Err(anyhow!( + "A reference {} already exists", + branch_reference.upstream + )); + } + // Ensure the commit is not already referenced + if all_references.iter().any(|r| r.commit_id == commit_id) { + return Err(anyhow!( + "A reference for commit {} already exists", + commit_id + )); + } + validate_commit(&vbranch, commit_id, ctx, &handle)?; + vbranch.references.push(branch_reference.clone()); + handle.set_branch(vbranch)?; + Ok(branch_reference) +} + +/// Updates an existing branch reference to point to a different commit. +/// Only the commit and change_id can be updated. +/// The reference is identified by the branch id and the reference name. +/// This function will return an error if: +/// - this reference does not exist +/// - the reference exists, but the commit id is not in the branch +/// - the reference exists, but the commit id is already associated with another reference +/// +/// If the commit ID is the same as the current commit ID, the function is a no-op. +/// If the change ID is provided, it will be updated, otherwise it will be left unchanged. +pub fn update_branch_reference( + ctx: &CommandContext, + branch_id: BranchId, + upstream: ReferenceName, + new_commit_id: git2::Oid, + new_change_id: Option, +) -> Result { + // The reference must be parseable as a remote reference + gitbutler_reference::RemoteRefname::from_str(&upstream) + .context("Failed to parse the provided reference")?; + let handle = VirtualBranchesHandle::new(ctx.project().gb_dir()); + // The branch must exist + let mut vbranch = handle.get_branch(branch_id)?; + + // Fail early if the commit is not valid + validate_commit(&vbranch, new_commit_id, ctx, &handle)?; + + let reference = vbranch + .references + .iter_mut() + .find(|r| r.upstream == upstream) + .ok_or(anyhow!( + "Reference {} not found for branch {}", + upstream, + branch_id + ))?; + reference.commit_id = new_commit_id; + reference.change_id = new_change_id.or(reference.change_id.clone()); + let new_reference = reference.clone(); + handle.set_branch(vbranch)?; + Ok(new_reference) +} + +/// Pushes a gitbutler branch reference to the remote repository. +pub fn push_branch_reference( + ctx: &CommandContext, + branch_id: BranchId, + upstream: ReferenceName, + with_force: bool, + credentials: &Helper, +) -> Result<()> { + let handle = VirtualBranchesHandle::new(ctx.project().gb_dir()); + let vbranch = handle.get_branch(branch_id)?; + let reference = vbranch + .references + .iter() + .find(|r| r.upstream == upstream) + .ok_or_else(|| anyhow!("Reference {} not found", upstream))?; + let upstream_refname = gitbutler_reference::RemoteRefname::from_str(&reference.upstream) + .context("Failed to parse the provided reference")?; + ctx.push( + &reference.commit_id, + &upstream_refname, + with_force, + credentials, + None, + Some(Some(branch_id)), + ) +} + +/// Validates a commit in the following ways: +/// - The reference does not already exists for any other branch +/// - There is no other reference already pointing to the commit +/// - The commit actually exists +/// - The commit is between the branch base and the branch head +fn validate_commit( + vbranch: &Branch, + commit_id: git2::Oid, + ctx: &CommandContext, + handle: &VirtualBranchesHandle, +) -> Result<()> { + // Enusre that the commit acutally exists + ctx.repository() + .find_commit(commit_id) + .context(anyhow!("Commit {} does not exist", commit_id))?; + + let target = handle.get_default_target()?; + let branch_commits = ctx + .log(vbranch.head, LogUntil::Commit(target.sha))? + .iter() + .map(|c| c.id()) + .collect_vec(); + + // Assert that the commit is between the branch base and the branch head + if !branch_commits.contains(&commit_id) { + return Err(anyhow!( + "The commit {} is not between the branch base and the branch head", + commit_id + )); + } + Ok(()) +} From 052270f078c973bab51c0a38621238b2af9d9671 Mon Sep 17 00:00:00 2001 From: Kiril Videlov Date: Wed, 21 Aug 2024 16:31:46 +0200 Subject: [PATCH 03/16] adds support for writable scripted fixtures --- crates/gitbutler-testsupport/src/lib.rs | 91 +++++++++++++++++-------- 1 file changed, 62 insertions(+), 29 deletions(-) diff --git a/crates/gitbutler-testsupport/src/lib.rs b/crates/gitbutler-testsupport/src/lib.rs index 90af71f646..41c134d78f 100644 --- a/crates/gitbutler-testsupport/src/lib.rs +++ b/crates/gitbutler-testsupport/src/lib.rs @@ -60,40 +60,48 @@ pub fn init_opts_bare() -> git2::RepositoryInitOptions { opts } -pub mod read_only { - use std::{ - collections::BTreeSet, - path::{Path, PathBuf}, - }; +pub mod writable { + use crate::DRIVER; + use gitbutler_command_context::CommandContext; + use gitbutler_project::{Project, ProjectId}; + use tempfile::TempDir; + + pub fn fixture( + script_name: &str, + project_directory: &str, + ) -> anyhow::Result<(CommandContext, TempDir)> { + let (project, tempdir) = fixture_project(script_name, project_directory)?; + let ctx = CommandContext::open(&project)?; + Ok((ctx, tempdir)) + } + pub fn fixture_project( + script_name: &str, + project_directory: &str, + ) -> anyhow::Result<(Project, TempDir)> { + let root = gix_testtools::scripted_fixture_writable_with_args( + script_name, + Some(DRIVER.display().to_string()), + gix_testtools::Creation::ExecuteScript, + ) + .expect("script execution always succeeds"); + let project = Project { + id: ProjectId::generate(), + title: project_directory.to_owned(), + path: root.path().join(project_directory), + ..Default::default() + }; + Ok((project, root)) + } +} + +pub mod read_only { + use crate::DRIVER; use gitbutler_command_context::CommandContext; use gitbutler_project::{Project, ProjectId}; use once_cell::sync::Lazy; use parking_lot::Mutex; - - static DRIVER: Lazy = Lazy::new(|| { - let mut cargo = std::process::Command::new(env!("CARGO")); - let res = cargo - .args(["build", "-p=gitbutler-cli"]) - .status() - .expect("cargo should run fine"); - assert!(res.success(), "cargo invocation should be successful"); - - let path = Path::new("../../target") - .join("debug") - .join(if cfg!(windows) { - "gitbutler-cli.exe" - } else { - "gitbutler-cli" - }); - assert!( - path.is_file(), - "Expecting driver to be located at {path:?} - we also assume a certain crate location" - ); - path.canonicalize().expect( - "canonicalization works as the CWD is valid and there are no symlinks to resolve", - ) - }); + use std::collections::BTreeSet; /// Execute the script at `script_name.sh` (assumed to be located in `tests/fixtures/`) /// and make the command-line application available to it. That way the script can perform GitButler @@ -138,6 +146,31 @@ pub mod read_only { } } +use once_cell::sync::Lazy; +use std::path::{Path, PathBuf}; +pub(crate) static DRIVER: Lazy = Lazy::new(|| { + let mut cargo = std::process::Command::new(env!("CARGO")); + let res = cargo + .args(["build", "-p=gitbutler-cli"]) + .status() + .expect("cargo should run fine"); + assert!(res.success(), "cargo invocation should be successful"); + + let path = Path::new("../../target") + .join("debug") + .join(if cfg!(windows) { + "gitbutler-cli.exe" + } else { + "gitbutler-cli" + }); + assert!( + path.is_file(), + "Expecting driver to be located at {path:?} - we also assume a certain crate location" + ); + path.canonicalize() + .expect("canonicalization works as the CWD is valid and there are no symlinks to resolve") +}); + /// A secrets store to prevent secrets to be written into the systems own store. /// /// Note that this can't be used if secrets themselves are under test as it' doesn't act From 36c0a1219d21aee6603bf45187847574af9fa82b Mon Sep 17 00:00:00 2001 From: Kiril Videlov Date: Wed, 21 Aug 2024 16:32:20 +0200 Subject: [PATCH 04/16] add tests --- Cargo.lock | 3 + crates/gitbutler-stack/Cargo.toml | 4 + .../tests/fixtures/stacking.sh | 43 +++ crates/gitbutler-stack/tests/mod.rs | 1 + crates/gitbutler-stack/tests/reference.rs | 270 ++++++++++++++++++ 5 files changed, 321 insertions(+) create mode 100644 crates/gitbutler-stack/tests/fixtures/stacking.sh create mode 100644 crates/gitbutler-stack/tests/mod.rs create mode 100644 crates/gitbutler-stack/tests/reference.rs diff --git a/Cargo.lock b/Cargo.lock index be6095512e..297f43dec2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2475,7 +2475,10 @@ dependencies = [ "gitbutler-command-context", "gitbutler-reference", "gitbutler-repo", + "gitbutler-testsupport", "itertools 0.13.0", + "pretty_assertions", + "tempfile", ] [[package]] diff --git a/crates/gitbutler-stack/Cargo.toml b/crates/gitbutler-stack/Cargo.toml index 8578aa6c96..d1d82a706c 100644 --- a/crates/gitbutler-stack/Cargo.toml +++ b/crates/gitbutler-stack/Cargo.toml @@ -14,3 +14,7 @@ gitbutler-reference.workspace = true gitbutler-repo.workspace = true itertools = "0.13" +[dev-dependencies] +pretty_assertions = "1.4" +gitbutler-testsupport.workspace = true +tempfile = "3.10.1" diff --git a/crates/gitbutler-stack/tests/fixtures/stacking.sh b/crates/gitbutler-stack/tests/fixtures/stacking.sh new file mode 100644 index 0000000000..d8ec072899 --- /dev/null +++ b/crates/gitbutler-stack/tests/fixtures/stacking.sh @@ -0,0 +1,43 @@ +#!/usr/bin/env bash +set -eu -o pipefail +CLI=${1:?The first argument is the GitButler CLI} + +function tick () { + if test -z "${tick+set}"; then + tick=1675176957 + else + tick=$(($tick + 60)) + fi + GIT_COMMITTER_DATE="$tick +0100" + GIT_AUTHOR_DATE="$tick +0100" + export GIT_COMMITTER_DATE GIT_AUTHOR_DATE +} +tick + +git init remote +(cd remote + echo first > file + git add . && git commit -m "init" +) + +export GITBUTLER_CLI_DATA_DIR=../user/gitbutler/app-data +git clone remote multiple-commits +(cd multiple-commits + $CLI project add --switch-to-integration "$(git rev-parse --symbolic-full-name @{u})" + + $CLI branch create --set-default first_branch + echo asdf >> foo + tick + $CLI branch commit first_branch -m "some commit" + + $CLI branch create --set-default virtual + echo change >> file + tick + $CLI branch commit virtual -m "first commit" + echo change2 >> file + tick + $CLI branch commit virtual -m "second commit" + echo change3 >> file + tick + $CLI branch commit virtual -m "third commit" +) diff --git a/crates/gitbutler-stack/tests/mod.rs b/crates/gitbutler-stack/tests/mod.rs new file mode 100644 index 0000000000..f9927c0bf8 --- /dev/null +++ b/crates/gitbutler-stack/tests/mod.rs @@ -0,0 +1 @@ +mod reference; diff --git a/crates/gitbutler-stack/tests/reference.rs b/crates/gitbutler-stack/tests/reference.rs new file mode 100644 index 0000000000..f135f043bc --- /dev/null +++ b/crates/gitbutler-stack/tests/reference.rs @@ -0,0 +1,270 @@ +use anyhow::Result; +use gitbutler_branch::VirtualBranchesHandle; +use gitbutler_command_context::CommandContext; +use gitbutler_repo::{credentials::Helper, LogUntil, RepoActionsExt}; +use gitbutler_stack::{ + create_branch_reference, list_branch_references, push_branch_reference, update_branch_reference, +}; +use tempfile::TempDir; + +#[test] +fn create_success() -> Result<()> { + let (ctx, _temp_dir) = command_ctx("multiple-commits")?; + let test_ctx = test_ctx(&ctx)?; + let reference = create_branch_reference( + &ctx, + test_ctx.branch.id, + "refs/remotes/origin/success".into(), + test_ctx.commits.first().unwrap().id(), + Some("change-id".into()), + )?; + assert_eq!(reference.branch_id, test_ctx.branch.id); + assert_eq!(reference.upstream, "refs/remotes/origin/success".into()); + assert_eq!(reference.commit_id, test_ctx.commits.first().unwrap().id()); + assert_eq!(reference.change_id, Some("change-id".into())); + Ok(()) +} + +#[test] +fn create_multiple() -> Result<()> { + let (ctx, _temp_dir) = command_ctx("multiple-commits")?; + let test_ctx = test_ctx(&ctx)?; + let first = create_branch_reference( + &ctx, + test_ctx.branch.id, + "refs/remotes/origin/first".into(), + test_ctx.commits.first().unwrap().id(), + None, + )?; + assert_eq!(first.branch_id, test_ctx.branch.id); + assert_eq!(first.upstream, "refs/remotes/origin/first".into()); + assert_eq!(first.commit_id, test_ctx.commits.first().unwrap().id()); + assert_eq!(first.change_id, None); + let last = create_branch_reference( + &ctx, + test_ctx.branch.id, + "refs/remotes/origin/last".into(), + test_ctx.commits.last().unwrap().id(), + None, + )?; + assert_eq!(last.branch_id, test_ctx.branch.id); + assert_eq!(last.upstream, "refs/remotes/origin/last".into()); + assert_eq!(last.commit_id, test_ctx.commits.last().unwrap().id()); + assert_eq!(last.change_id, None); + Ok(()) +} + +#[test] +fn create_fails_with_non_remote_reference() -> Result<()> { + let (ctx, _temp_dir) = command_ctx("multiple-commits")?; + let test_ctx = test_ctx(&ctx)?; + let result = create_branch_reference( + &ctx, + test_ctx.branch.id, + "foo".into(), + test_ctx.commits.first().unwrap().id(), + None, + ); + assert_eq!( + result.unwrap_err().to_string(), + "Failed to parse the provided reference", + ); + Ok(()) +} + +#[test] +fn create_fails_when_branch_reference_with_name_exists() -> Result<()> { + let (ctx, _temp_dir) = command_ctx("multiple-commits")?; + let test_ctx = test_ctx(&ctx)?; + create_branch_reference( + &ctx, + test_ctx.branch.id, + "refs/remotes/origin/taken".into(), + test_ctx.commits.first().unwrap().id(), + None, + )?; + let result = create_branch_reference( + &ctx, + test_ctx.branch.id, + "refs/remotes/origin/taken".into(), + test_ctx.commits.last().unwrap().id(), + None, + ); + assert_eq!( + result.unwrap_err().to_string(), + format!("A reference refs/remotes/origin/taken already exists",), + ); + + Ok(()) +} + +#[test] +fn create_fails_when_commit_already_referenced() -> Result<()> { + let (ctx, _temp_dir) = command_ctx("multiple-commits")?; + let test_ctx = test_ctx(&ctx)?; + create_branch_reference( + &ctx, + test_ctx.branch.id, + "refs/remotes/origin/one".into(), + test_ctx.commits.first().unwrap().id(), + None, + )?; + let result = create_branch_reference( + &ctx, + test_ctx.branch.id, + "refs/remotes/origin/two".into(), + test_ctx.commits.first().unwrap().id(), + None, + ); + assert_eq!( + result.unwrap_err().to_string(), + format!( + "A reference for commit {} already exists", + test_ctx.commits.first().unwrap().id() + ), + ); + + Ok(()) +} + +#[test] +fn create_fails_when_commit_in_anothe_branch() -> Result<()> { + let (ctx, _temp_dir) = command_ctx("multiple-commits")?; + let test_ctx = test_ctx(&ctx)?; + let wrong_commit = test_ctx.other_commits.first().unwrap().id(); + let result = create_branch_reference( + &ctx, + test_ctx.branch.id, + "refs/remotes/origin/asdf".into(), + wrong_commit, + None, + ); + assert_eq!( + result.unwrap_err().to_string(), + format!( + "The commit {} is not between the branch base and the branch head", + wrong_commit + ), + ); + Ok(()) +} + +#[test] +fn create_fails_when_commit_is_the_base() -> Result<()> { + let (ctx, _temp_dir) = command_ctx("multiple-commits")?; + let test_ctx = test_ctx(&ctx)?; + let result = create_branch_reference( + &ctx, + test_ctx.branch.id, + "refs/remotes/origin/baz".into(), + test_ctx.branch_base, + None, + ); + assert_eq!( + result.unwrap_err().to_string(), + format!( + "The commit {} is not between the branch base and the branch head", + test_ctx.branch_base + ), + ); + Ok(()) +} + +#[test] +fn list_success() -> Result<()> { + let (ctx, _temp_dir) = command_ctx("multiple-commits")?; + let test_ctx = test_ctx(&ctx)?; + let first_ref = create_branch_reference( + &ctx, + test_ctx.branch.id, + "refs/remotes/origin/first".into(), + test_ctx.commits.first().unwrap().id(), + Some("change-id".into()), + )?; + let second_ref = create_branch_reference( + &ctx, + test_ctx.branch.id, + "refs/remotes/origin/second".into(), + test_ctx.commits.last().unwrap().id(), + Some("change-id".into()), + )?; + let result = list_branch_references(&ctx, test_ctx.branch.id)?; + assert_eq!(result.len(), 2); + assert_eq!(result[0], first_ref); + assert_eq!(result[1], second_ref); + Ok(()) +} + +#[test] +fn update_success() -> Result<()> { + let (ctx, _temp_dir) = command_ctx("multiple-commits")?; + let test_ctx = test_ctx(&ctx)?; + create_branch_reference( + &ctx, + test_ctx.branch.id, + "refs/remotes/origin/first".into(), + test_ctx.commits.first().unwrap().id(), + Some("change-id".into()), + )?; + let updated = update_branch_reference( + &ctx, + test_ctx.branch.id, + "refs/remotes/origin/first".into(), + test_ctx.commits.last().unwrap().id(), + None, + )?; + assert_eq!(updated.commit_id, test_ctx.commits.last().unwrap().id()); + let list = list_branch_references(&ctx, test_ctx.branch.id)?; + assert_eq!(list.len(), 1); + assert_eq!(list[0].commit_id, test_ctx.commits.last().unwrap().id()); + Ok(()) +} + +#[test] +fn push_success() -> Result<()> { + let (ctx, _temp_dir) = command_ctx("multiple-commits")?; + let test_ctx = test_ctx(&ctx)?; + let reference = create_branch_reference( + &ctx, + test_ctx.branch.id, + "refs/remotes/origin/first".into(), + test_ctx.commits.first().unwrap().id(), + Some("change-id".into()), + )?; + let result = push_branch_reference( + &ctx, + reference.branch_id, + reference.upstream, + false, + &Helper::default(), + ); + assert!(result.is_ok()); + Ok(()) +} + +fn command_ctx(name: &str) -> Result<(CommandContext, TempDir)> { + gitbutler_testsupport::writable::fixture("stacking.sh", name) +} + +fn test_ctx(ctx: &CommandContext) -> Result { + let handle = VirtualBranchesHandle::new(ctx.project().gb_dir()); + let branches = handle.list_all_branches()?; + let branch = branches.iter().find(|b| b.name == "virtual").unwrap(); + let other_branch = branches.iter().find(|b| b.name != "virtual").unwrap(); + let target = handle.get_default_target()?; + let branch_base = ctx.repository().merge_base(target.sha, branch.head)?; + let branch_commits = ctx.log(branch.head, LogUntil::Commit(target.sha))?; + let other_commits = ctx.log(other_branch.head, LogUntil::Commit(target.sha))?; + Ok(TestContext { + branch: branch.clone(), + branch_base, + commits: branch_commits, + other_commits, + }) +} +struct TestContext<'a> { + branch: gitbutler_branch::Branch, + branch_base: git2::Oid, + commits: Vec>, + other_commits: Vec>, +} From 9d6508b8499c08b438df135fc6498da33b9ca30e Mon Sep 17 00:00:00 2001 From: Kiril Videlov Date: Thu, 22 Aug 2024 23:21:26 +0200 Subject: [PATCH 05/16] support listing references by commits --- crates/gitbutler-stack/src/lib.rs | 3 +- crates/gitbutler-stack/src/reference.rs | 24 ++++++++++++ crates/gitbutler-stack/tests/reference.rs | 45 ++++++++++++++++++++++- 3 files changed, 70 insertions(+), 2 deletions(-) diff --git a/crates/gitbutler-stack/src/lib.rs b/crates/gitbutler-stack/src/lib.rs index d32a62bbf6..b4a5e100f0 100644 --- a/crates/gitbutler-stack/src/lib.rs +++ b/crates/gitbutler-stack/src/lib.rs @@ -1,4 +1,5 @@ mod reference; pub use reference::{ - create_branch_reference, list_branch_references, push_branch_reference, update_branch_reference, + create_branch_reference, list_branch_references, list_commit_references, push_branch_reference, + update_branch_reference, }; diff --git a/crates/gitbutler-stack/src/reference.rs b/crates/gitbutler-stack/src/reference.rs index 81c0a397f8..c4d2f4dfc9 100644 --- a/crates/gitbutler-stack/src/reference.rs +++ b/crates/gitbutler-stack/src/reference.rs @@ -1,3 +1,4 @@ +use std::collections::HashMap; use std::str::FromStr; use anyhow::Context; @@ -22,6 +23,29 @@ pub fn list_branch_references( Ok(vbranch.references) } +/// Given a list of commits ids, returns a map of commit ids to the references that point to them or None +pub fn list_commit_references( + ctx: &CommandContext, + commits: Vec, +) -> Result>> { + let handle = VirtualBranchesHandle::new(ctx.project().gb_dir()); + let all_references = handle + .list_all_branches()? + .into_iter() + .flat_map(|branch| branch.references) + .collect_vec(); + Ok(commits + .into_iter() + .map(|commit_id| { + let reference = all_references + .iter() + .find(|r| r.commit_id == commit_id) + .cloned(); + (commit_id, reference) + }) + .collect()) +} + /// Creates a new virtual branch reference and associates it with the branch. /// However this will return an error if: /// - a reference for the same commit already exists, an error is returned. diff --git a/crates/gitbutler-stack/tests/reference.rs b/crates/gitbutler-stack/tests/reference.rs index f135f043bc..a3df7d47aa 100644 --- a/crates/gitbutler-stack/tests/reference.rs +++ b/crates/gitbutler-stack/tests/reference.rs @@ -3,7 +3,8 @@ use gitbutler_branch::VirtualBranchesHandle; use gitbutler_command_context::CommandContext; use gitbutler_repo::{credentials::Helper, LogUntil, RepoActionsExt}; use gitbutler_stack::{ - create_branch_reference, list_branch_references, push_branch_reference, update_branch_reference, + create_branch_reference, list_branch_references, list_commit_references, push_branch_reference, + update_branch_reference, }; use tempfile::TempDir; @@ -242,6 +243,46 @@ fn push_success() -> Result<()> { Ok(()) } +#[test] +fn list_by_commits_success() -> Result<()> { + let (ctx, _temp_dir) = command_ctx("multiple-commits")?; + let test_ctx = test_ctx(&ctx)?; + let first = create_branch_reference( + &ctx, + test_ctx.branch.id, + "refs/remotes/origin/first".into(), + test_ctx.commits.first().unwrap().id(), + None, + )?; + let second = create_branch_reference( + &ctx, + test_ctx.branch.id, + "refs/remotes/origin/second".into(), + test_ctx.commits.last().unwrap().id(), + None, + )?; + let third = create_branch_reference( + &ctx, + test_ctx.other_branch.id, + "refs/remotes/origin/third".into(), + test_ctx.other_commits.first().unwrap().id(), + None, + )?; + let commits = vec![ + test_ctx.commits.first().unwrap().id(), + test_ctx.commits.get(1).unwrap().id(), + test_ctx.commits.last().unwrap().id(), + test_ctx.other_commits.first().unwrap().id(), + ]; + let result = list_commit_references(&ctx, commits.clone())?; + assert_eq!(result.len(), 4); + assert_eq!(result.get(&commits[0]).unwrap().clone().unwrap(), first); + assert_eq!(result.get(&commits[1]).unwrap().clone(), None); + assert_eq!(result.get(&commits[2]).unwrap().clone().unwrap(), second); + assert_eq!(result.get(&commits[3]).unwrap().clone().unwrap(), third); + Ok(()) +} + fn command_ctx(name: &str) -> Result<(CommandContext, TempDir)> { gitbutler_testsupport::writable::fixture("stacking.sh", name) } @@ -259,6 +300,7 @@ fn test_ctx(ctx: &CommandContext) -> Result { branch: branch.clone(), branch_base, commits: branch_commits, + other_branch: other_branch.clone(), other_commits, }) } @@ -266,5 +308,6 @@ struct TestContext<'a> { branch: gitbutler_branch::Branch, branch_base: git2::Oid, commits: Vec>, + other_branch: gitbutler_branch::Branch, other_commits: Vec>, } From 12f826b9add7b3dd26df82dd819233f120e749be Mon Sep 17 00:00:00 2001 From: Kiril Videlov Date: Thu, 22 Aug 2024 23:29:13 +0200 Subject: [PATCH 06/16] move the stacking impl to repo crate There is just too big of a dependency in both directions to be able to have a separation at this time. The gitbutler-repo crate is needed for the stacking functionality and the `rebase` module inside of it needs to use the new API in order to maintain consistency during rebasing. --- Cargo.lock | 17 +--------------- Cargo.toml | 2 -- crates/gitbutler-repo/Cargo.toml | 1 + crates/gitbutler-repo/src/lib.rs | 7 +++++++ .../src/reference.rs | 4 ++-- .../tests/fixtures/stacking.sh | 0 crates/gitbutler-repo/tests/mod.rs | 1 + .../tests/reference.rs | 7 +++---- crates/gitbutler-stack/Cargo.toml | 20 ------------------- crates/gitbutler-stack/src/lib.rs | 5 ----- crates/gitbutler-stack/tests/mod.rs | 1 - 11 files changed, 15 insertions(+), 50 deletions(-) rename crates/{gitbutler-stack => gitbutler-repo}/src/reference.rs (98%) rename crates/{gitbutler-stack => gitbutler-repo}/tests/fixtures/stacking.sh (100%) rename crates/{gitbutler-stack => gitbutler-repo}/tests/reference.rs (97%) delete mode 100644 crates/gitbutler-stack/Cargo.toml delete mode 100644 crates/gitbutler-stack/src/lib.rs delete mode 100644 crates/gitbutler-stack/tests/mod.rs diff --git a/Cargo.lock b/Cargo.lock index 297f43dec2..bfe2d93537 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2432,6 +2432,7 @@ dependencies = [ "gitbutler-url", "gitbutler-user", "gix", + "itertools 0.13.0", "log", "resolve-path", "serde", @@ -2465,22 +2466,6 @@ dependencies = [ "serde", ] -[[package]] -name = "gitbutler-stack" -version = "0.0.0" -dependencies = [ - "anyhow", - "git2", - "gitbutler-branch", - "gitbutler-command-context", - "gitbutler-reference", - "gitbutler-repo", - "gitbutler-testsupport", - "itertools 0.13.0", - "pretty_assertions", - "tempfile", -] - [[package]] name = "gitbutler-storage" version = "0.0.0" diff --git a/Cargo.toml b/Cargo.toml index d5315eb9d2..fafb23d931 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -30,7 +30,6 @@ members = [ "crates/gitbutler-diff", "crates/gitbutler-operating-modes", "crates/gitbutler-edit-mode", - "crates/gitbutler-stack", ] resolver = "2" @@ -83,7 +82,6 @@ gitbutler-url = { path = "crates/gitbutler-url" } gitbutler-diff = { path = "crates/gitbutler-diff" } gitbutler-operating-modes = { path = "crates/gitbutler-operating-modes" } gitbutler-edit-mode = { path = "crates/gitbutler-edit-mode" } -gitbutler-stack = { path = "crates/gitbutler-stack" } [profile.release] codegen-units = 1 # Compile crates one after another so the compiler can optimize better diff --git a/crates/gitbutler-repo/Cargo.toml b/crates/gitbutler-repo/Cargo.toml index 585b35669d..3ffa08b3e8 100644 --- a/crates/gitbutler-repo/Cargo.toml +++ b/crates/gitbutler-repo/Cargo.toml @@ -35,6 +35,7 @@ gitbutler-time.workspace = true gitbutler-commit.workspace = true gitbutler-url.workspace = true uuid.workspace = true +itertools = "0.13" [[test]] name = "repo" diff --git a/crates/gitbutler-repo/src/lib.rs b/crates/gitbutler-repo/src/lib.rs index 7de9e79c4b..bd5a344114 100644 --- a/crates/gitbutler-repo/src/lib.rs +++ b/crates/gitbutler-repo/src/lib.rs @@ -18,3 +18,10 @@ pub use config::Config; pub mod askpass; mod conflicts; + +mod reference; + +pub use reference::{ + create_branch_reference, list_branch_references, list_commit_references, push_branch_reference, + update_branch_reference, +}; diff --git a/crates/gitbutler-stack/src/reference.rs b/crates/gitbutler-repo/src/reference.rs similarity index 98% rename from crates/gitbutler-stack/src/reference.rs rename to crates/gitbutler-repo/src/reference.rs index c4d2f4dfc9..ae54b76d04 100644 --- a/crates/gitbutler-stack/src/reference.rs +++ b/crates/gitbutler-repo/src/reference.rs @@ -1,6 +1,8 @@ use std::collections::HashMap; use std::str::FromStr; +use crate::credentials::Helper; +use crate::{LogUntil, RepoActionsExt}; use anyhow::Context; use anyhow::{anyhow, Result}; use gitbutler_branch::BranchReference; @@ -8,8 +10,6 @@ use gitbutler_branch::VirtualBranchesHandle; use gitbutler_branch::{Branch, BranchId}; use gitbutler_command_context::CommandContext; use gitbutler_reference::ReferenceName; -use gitbutler_repo::credentials::Helper; -use gitbutler_repo::{LogUntil, RepoActionsExt}; use itertools::Itertools; /// Given a branch id, returns the the GitButler references associated with the branch. diff --git a/crates/gitbutler-stack/tests/fixtures/stacking.sh b/crates/gitbutler-repo/tests/fixtures/stacking.sh similarity index 100% rename from crates/gitbutler-stack/tests/fixtures/stacking.sh rename to crates/gitbutler-repo/tests/fixtures/stacking.sh diff --git a/crates/gitbutler-repo/tests/mod.rs b/crates/gitbutler-repo/tests/mod.rs index bb5fd604b5..d395c5b310 100644 --- a/crates/gitbutler-repo/tests/mod.rs +++ b/crates/gitbutler-repo/tests/mod.rs @@ -1 +1,2 @@ mod credentials; +mod reference; diff --git a/crates/gitbutler-stack/tests/reference.rs b/crates/gitbutler-repo/tests/reference.rs similarity index 97% rename from crates/gitbutler-stack/tests/reference.rs rename to crates/gitbutler-repo/tests/reference.rs index a3df7d47aa..e35aa1f6b7 100644 --- a/crates/gitbutler-stack/tests/reference.rs +++ b/crates/gitbutler-repo/tests/reference.rs @@ -1,10 +1,9 @@ use anyhow::Result; use gitbutler_branch::VirtualBranchesHandle; use gitbutler_command_context::CommandContext; -use gitbutler_repo::{credentials::Helper, LogUntil, RepoActionsExt}; -use gitbutler_stack::{ - create_branch_reference, list_branch_references, list_commit_references, push_branch_reference, - update_branch_reference, +use gitbutler_repo::{ + create_branch_reference, credentials::Helper, list_branch_references, list_commit_references, + push_branch_reference, update_branch_reference, LogUntil, RepoActionsExt, }; use tempfile::TempDir; diff --git a/crates/gitbutler-stack/Cargo.toml b/crates/gitbutler-stack/Cargo.toml deleted file mode 100644 index d1d82a706c..0000000000 --- a/crates/gitbutler-stack/Cargo.toml +++ /dev/null @@ -1,20 +0,0 @@ -[package] -name = "gitbutler-stack" -version = "0.0.0" -edition = "2021" -authors = ["GitButler "] -publish = false - -[dependencies] -git2.workspace = true -anyhow.workspace = true -gitbutler-command-context.workspace = true -gitbutler-branch.workspace = true -gitbutler-reference.workspace = true -gitbutler-repo.workspace = true -itertools = "0.13" - -[dev-dependencies] -pretty_assertions = "1.4" -gitbutler-testsupport.workspace = true -tempfile = "3.10.1" diff --git a/crates/gitbutler-stack/src/lib.rs b/crates/gitbutler-stack/src/lib.rs deleted file mode 100644 index b4a5e100f0..0000000000 --- a/crates/gitbutler-stack/src/lib.rs +++ /dev/null @@ -1,5 +0,0 @@ -mod reference; -pub use reference::{ - create_branch_reference, list_branch_references, list_commit_references, push_branch_reference, - update_branch_reference, -}; diff --git a/crates/gitbutler-stack/tests/mod.rs b/crates/gitbutler-stack/tests/mod.rs deleted file mode 100644 index f9927c0bf8..0000000000 --- a/crates/gitbutler-stack/tests/mod.rs +++ /dev/null @@ -1 +0,0 @@ -mod reference; From aea72e8605c146d4a595fbd0811e4be006525e11 Mon Sep 17 00:00:00 2001 From: Kiril Videlov Date: Fri, 23 Aug 2024 00:03:58 +0200 Subject: [PATCH 07/16] update any branch references during cherry rebase --- crates/gitbutler-repo/src/rebase.rs | 49 ++++++++++++++++++++++++++--- 1 file changed, 45 insertions(+), 4 deletions(-) diff --git a/crates/gitbutler-repo/src/rebase.rs b/crates/gitbutler-repo/src/rebase.rs index bd28cd14fe..0e85147639 100644 --- a/crates/gitbutler-repo/src/rebase.rs +++ b/crates/gitbutler-repo/src/rebase.rs @@ -1,6 +1,9 @@ +use std::collections::HashMap; + use anyhow::{anyhow, Context, Result}; use bstr::ByteSlice; use git2::{build::TreeUpdateBuilder, Repository}; +use gitbutler_branch::BranchReference; use gitbutler_command_context::CommandContext; use gitbutler_commit::{ commit_ext::CommitExt, @@ -10,7 +13,10 @@ use gitbutler_error::error::Marker; use tempfile::tempdir; use uuid::Uuid; -use crate::{conflicts::ConflictedTreeKey, LogUntil, RepoActionsExt, RepositoryExt}; +use crate::{ + conflicts::ConflictedTreeKey, list_commit_references, update_branch_reference, LogUntil, + RepoActionsExt, RepositoryExt, +}; /// cherry-pick based rebase, which handles empty commits /// this function takes a commit range and generates a Vector of commit oids @@ -45,6 +51,8 @@ pub fn cherry_rebase_group( ids_to_rebase: &mut [git2::Oid], ) -> Result { ids_to_rebase.reverse(); + // Attempt to list any GitButler references pointing to the commits to rebase, and default to an empty list if it fails + let references = list_commit_references(ctx, ids_to_rebase.to_vec()).unwrap_or_default(); // now, rebase unchanged commits onto the new commit let commits_to_rebase = ids_to_rebase .iter() @@ -67,14 +75,26 @@ pub fn cherry_rebase_group( .cherry_pick_gitbutler(&head, &to_rebase) .context("failed to cherry pick")?; - if cherrypick_index.has_conflicts() { + let result = if cherrypick_index.has_conflicts() { if !ctx.project().succeeding_rebases { return Err(anyhow!("failed to rebase")).context(Marker::BranchConflict); } - commit_conflicted_cherry_result(ctx, head, to_rebase, cherrypick_index) + commit_conflicted_cherry_result(ctx, head.clone(), to_rebase, cherrypick_index) } else { - commit_unconflicted_cherry_result(ctx, head, to_rebase, cherrypick_index) + commit_unconflicted_cherry_result( + ctx, + head.clone(), + to_rebase, + cherrypick_index, + ) + }; + if let Ok(commit) = &result { + // If the commit got successfully rebased and if there were any references pointiong to it, + // update them to point to the new commit. Ignore any errors that might occur during this process. + // TODO: some logging on error would be nice + let _ = update_existing_branch_references(ctx, &references, &head, commit); } + result }, )? .id(); @@ -82,6 +102,27 @@ pub fn cherry_rebase_group( Ok(new_head_id) } +fn update_existing_branch_references( + ctx: &CommandContext, + references: &HashMap>, + old_commit: &git2::Commit, + new_commit: &git2::Commit, +) -> Result> { + references + .get(&old_commit.id()) + .and_then(|reference| reference.as_ref()) + .map_or(Ok(None), |reference| { + update_branch_reference( + ctx, + reference.branch_id, + reference.upstream.clone(), + new_commit.id(), + new_commit.change_id(), + ) + .map(Some) + }) +} + fn commit_unconflicted_cherry_result<'repository>( ctx: &'repository CommandContext, head: git2::Commit<'repository>, From 7137fce76ea2e2d55c57bb9e1a69111dad45d57a Mon Sep 17 00:00:00 2001 From: Kiril Videlov Date: Fri, 23 Aug 2024 13:46:17 +0200 Subject: [PATCH 08/16] add ui feature flag for branch stacking --- apps/desktop/src/lib/config/uiFeatureFlags.ts | 5 +++++ .../routes/settings/experimental/+page.svelte | 17 ++++++++++++++++- 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/apps/desktop/src/lib/config/uiFeatureFlags.ts b/apps/desktop/src/lib/config/uiFeatureFlags.ts index 41df11a8fa..f72b07f71b 100644 --- a/apps/desktop/src/lib/config/uiFeatureFlags.ts +++ b/apps/desktop/src/lib/config/uiFeatureFlags.ts @@ -15,3 +15,8 @@ export function featureInlineUnifiedDiffs(): Persisted { const key = 'inlineUnifiedDiffs'; return persisted(false, key); } + +export function featureBranchStacking(): Persisted { + const key = 'branchStacking'; + return persisted(false, key); +} diff --git a/apps/desktop/src/routes/settings/experimental/+page.svelte b/apps/desktop/src/routes/settings/experimental/+page.svelte index 225e66b2b1..4fe8c8e6e8 100644 --- a/apps/desktop/src/routes/settings/experimental/+page.svelte +++ b/apps/desktop/src/routes/settings/experimental/+page.svelte @@ -2,13 +2,15 @@ import SectionCard from '$lib/components/SectionCard.svelte'; import { featureBaseBranchSwitching, - featureInlineUnifiedDiffs + featureInlineUnifiedDiffs, + featureBranchStacking } from '$lib/config/uiFeatureFlags'; import ContentWrapper from '$lib/settings/ContentWrapper.svelte'; import Toggle from '$lib/shared/Toggle.svelte'; const baseBranchSwitching = featureBaseBranchSwitching(); const inlineUnifiedDiffs = featureInlineUnifiedDiffs(); + const branchStacking = featureBranchStacking(); @@ -45,6 +47,19 @@ /> + + Branch stacking + + Allows for branch / pull request stacking. The user interface for this is still very crude. + + + ($branchStacking = !$branchStacking)} + /> + +