diff --git a/Cargo.lock b/Cargo.lock index 404f96a39..a8845e0f3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2058,8 +2058,13 @@ dependencies = [ "anyhow", "chrono", "clap", + "dirs-next", + "futures", + "gitbutler-branch", + "gitbutler-branch-actions", "gitbutler-oplog", "gitbutler-project", + "gitbutler-reference", "gix", ] diff --git a/Cargo.toml b/Cargo.toml index 868038db7..189870b7d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -47,6 +47,7 @@ keyring = "2.3.3" anyhow = "1.0.86" fslock = "0.2.1" parking_lot = "0.12.3" +futures = "0.3.30" gitbutler-id = { path = "crates/gitbutler-id" } gitbutler-git = { path = "crates/gitbutler-git" } diff --git a/crates/gitbutler-branch-actions/Cargo.toml b/crates/gitbutler-branch-actions/Cargo.toml index 507bd7459..09ba81ea5 100644 --- a/crates/gitbutler-branch-actions/Cargo.toml +++ b/crates/gitbutler-branch-actions/Cargo.toml @@ -32,7 +32,7 @@ regex = "1.10" git2-hooks = "0.3" url = { version = "2.5.2", features = ["serde"] } md5 = "0.7.0" -futures = "0.3" +futures.workspace = true itertools = "0.13" gitbutler-command-context.workspace = true gitbutler-project.workspace = true diff --git a/crates/gitbutler-branch-actions/tests/fixtures/for-listing.sh b/crates/gitbutler-branch-actions/tests/fixtures/for-listing.sh index 2e952028a..6b3515647 100644 --- a/crates/gitbutler-branch-actions/tests/fixtures/for-listing.sh +++ b/crates/gitbutler-branch-actions/tests/fixtures/for-listing.sh @@ -1,5 +1,6 @@ #!/usr/bin/env bash set -eu -o pipefail +CLI=${1:?The first argument is the GitButler CLI} git init remote (cd remote @@ -15,4 +16,10 @@ git clone remote single-branch-no-vbranch-multi-remote git fetch other-origin ) +export GITBUTLER_CLI_DATA_DIR=./git/gitbutler/app-data +git clone remote one-vbranch-on-integration +(cd one-vbranch-on-integration + $CLI project add --switch-to-integration "$(git rev-parse --symbolic-full-name @{u})" + $CLI branch create virtual +) diff --git a/crates/gitbutler-branch-actions/tests/virtual_branches/list.rs b/crates/gitbutler-branch-actions/tests/virtual_branches/list.rs index c74943c60..393881311 100644 --- a/crates/gitbutler-branch-actions/tests/virtual_branches/list.rs +++ b/crates/gitbutler-branch-actions/tests/virtual_branches/list.rs @@ -9,7 +9,7 @@ fn on_main_single_branch_no_vbranch() -> Result<()> { let branch = &list[0]; assert_eq!(branch.name, "main", "short names are used"); - assert_eq!(branch.remotes, &["origin"]); + assert_eq!(branch.remotes, ["origin"]); assert_eq!(branch.virtual_branch, None); assert_eq!( branch.authors, @@ -26,12 +26,31 @@ fn on_main_single_branch_no_vbranch_multiple_remotes() -> Result<()> { let branch = &list[0]; assert_eq!(branch.name, "main"); - assert_eq!(branch.remotes, &["other-origin", "origin"]); + assert_eq!(branch.remotes, ["other-origin", "origin"]); assert_eq!(branch.virtual_branch, None); assert_eq!(branch.authors, []); Ok(()) } +#[test] +fn one_vbranch_on_integration() -> Result<()> { + let list = list_branches(&project_ctx("one-vbranch-on-integration")?, None)?; + assert_eq!(list.len(), 1); + + let branch = &list[0]; + assert_eq!(branch.name, "virtual"); + assert!(branch.remotes.is_empty(), "no remote is associated yet"); + assert_eq!( + branch + .virtual_branch + .as_ref() + .map(|v| v.given_name.as_str()), + Some("virtual") + ); + assert_eq!(branch.authors, []); + Ok(()) +} + fn project_ctx(name: &str) -> anyhow::Result { gitbutler_testsupport::read_only::fixture("for-listing.sh", name) } diff --git a/crates/gitbutler-cli/Cargo.toml b/crates/gitbutler-cli/Cargo.toml index c1b70c23f..e4e3c42fd 100644 --- a/crates/gitbutler-cli/Cargo.toml +++ b/crates/gitbutler-cli/Cargo.toml @@ -12,7 +12,12 @@ path = "src/main.rs" [dependencies] gitbutler-oplog.workspace = true gitbutler-project.workspace = true +gitbutler-reference.workspace = true +gitbutler-branch-actions.workspace = true +gitbutler-branch.workspace = true gix.workspace = true -clap = { version = "4.5.9", features = ["derive"] } +futures.workspace = true +dirs-next = "2.0.0" +clap = { version = "4.5.9", features = ["derive", "env"] } anyhow = "1.0.86" chrono = "0.4.10" diff --git a/crates/gitbutler-cli/src/args.rs b/crates/gitbutler-cli/src/args.rs index 4a3b6c1f9..09c0c893c 100644 --- a/crates/gitbutler-cli/src/args.rs +++ b/crates/gitbutler-cli/src/args.rs @@ -13,10 +13,74 @@ pub struct Args { #[derive(Debug, clap::Subcommand)] pub enum Subcommands { + /// List and manipulate virtual branches. + #[clap(visible_alias = "branches")] + Branch(vbranch::Platform), + /// List and manipulate projects. + #[clap(visible_alias = "projects")] + Project(project::Platform), /// List and restore snapshots. + #[clap(visible_alias = "snapshots")] Snapshot(snapshot::Platform), } +pub mod vbranch { + #[derive(Debug, clap::Parser)] + pub struct Platform { + #[clap(subcommand)] + pub cmd: Option, + } + + #[derive(Debug, clap::Subcommand)] + pub enum SubCommands { + /// Create a new virtual branch + Create { + /// The name of the virtual branch to create + name: String, + }, + } +} + +pub mod project { + use gitbutler_reference::RemoteRefname; + use std::path::PathBuf; + + #[derive(Debug, clap::Parser)] + pub struct Platform { + /// The location of the directory to contain app data. + /// + /// Defaults to the standard location on this platform if unset. + #[clap(short = 'd', long, env = "GITBUTLER_CLI_DATA_DIR")] + pub app_data_dir: Option, + /// A suffix like `dev` to refer to projects of the development version of the application. + /// + /// The production version is used if unset. + #[clap(short = 's', long)] + pub app_suffix: Option, + #[clap(subcommand)] + pub cmd: Option, + } + + #[derive(Debug, clap::Subcommand)] + pub enum SubCommands { + /// Add the given Git repository as project for use with GitButler. + Add { + /// The long name of the remote reference to track, like `refs/remotes/origin/main`, + /// when switching to the integration branch. + #[clap(short = 's', long)] + switch_to_integration: Option, + /// The path at which the repository worktree is located. + #[clap(default_value = ".", value_name = "REPOSITORY")] + path: PathBuf, + }, + /// Switch back to the integration branch for use of virtual branches. + SwitchToIntegration { + /// The long name of the remote reference to track, like `refs/remotes/origin/main`. + remote_ref_name: RemoteRefname, + }, + } +} + pub mod snapshot { #[derive(Debug, clap::Parser)] pub struct Platform { diff --git a/crates/gitbutler-cli/src/command.rs b/crates/gitbutler-cli/src/command.rs index b8be4a9d4..955373384 100644 --- a/crates/gitbutler-cli/src/command.rs +++ b/crates/gitbutler-cli/src/command.rs @@ -1,3 +1,81 @@ +pub mod vbranch { + use crate::command::debug_print; + use futures::executor::block_on; + use gitbutler_branch::{BranchCreateRequest, VirtualBranchesHandle}; + use gitbutler_branch_actions::VirtualBranchActions; + use gitbutler_project::Project; + + pub fn list(project: Project) -> anyhow::Result<()> { + let branches = VirtualBranchesHandle::new(project.gb_dir()).list_all_branches()?; + for vbranch in branches { + println!( + "{active} {id} {name} {upstream}", + active = if vbranch.applied { "✔️" } else { "⛌" }, + id = vbranch.id, + name = vbranch.name, + upstream = vbranch + .upstream + .map_or_else(Default::default, |b| b.to_string()) + ); + } + Ok(()) + } + + pub fn create(project: Project, branch_name: String) -> anyhow::Result<()> { + debug_print(block_on(VirtualBranchActions.create_virtual_branch( + &project, + &BranchCreateRequest { + name: Some(branch_name), + ..Default::default() + }, + ))?) + } +} + +pub mod project { + use crate::command::debug_print; + use anyhow::{Context, Result}; + use futures::executor::block_on; + use gitbutler_branch_actions::VirtualBranchActions; + use gitbutler_project::Project; + use gitbutler_reference::RemoteRefname; + use std::path::PathBuf; + + pub fn list(ctrl: gitbutler_project::Controller) -> Result<()> { + for project in ctrl.list()? { + println!( + "{id} {name} {path}", + id = project.id, + name = project.title, + path = project.path.display() + ); + } + Ok(()) + } + + pub fn add( + ctrl: gitbutler_project::Controller, + path: PathBuf, + refname: Option, + ) -> Result<()> { + let path = gix::discover(path)? + .work_dir() + .context("Only non-bare repositories can be added")? + .to_owned() + .canonicalize()?; + let project = ctrl.add(path)?; + if let Some(refname) = refname { + block_on(VirtualBranchActions.set_base_branch(&project, &refname))?; + }; + debug_print(project) + } + + pub fn switch_to_integration(project: Project, refname: RemoteRefname) -> Result<()> { + debug_print(block_on( + VirtualBranchActions.set_base_branch(&project, &refname), + )?) + } +} pub mod snapshot { use anyhow::Result; use gitbutler_oplog::OplogExt; @@ -21,3 +99,55 @@ pub mod snapshot { Ok(()) } } + +pub mod prepare { + use anyhow::{bail, Context}; + use gitbutler_project::Project; + use std::path::PathBuf; + + pub fn project_from_path(path: PathBuf) -> anyhow::Result { + let worktree_dir = gix::discover(path)? + .work_dir() + .context("Bare repositories aren't supported")? + .to_owned(); + Ok(Project { + path: worktree_dir, + ..Default::default() + }) + } + + pub fn project_controller( + app_suffix: Option, + app_data_dir: Option, + ) -> anyhow::Result { + let path = if let Some(dir) = app_data_dir { + std::fs::create_dir_all(&dir) + .context("Failed to assure the designated data-dir exists")?; + dir + } else { + dirs_next::data_dir() + .map(|dir| { + dir.join(format!( + "com.gitbutler.app{}", + app_suffix + .map(|mut suffix| { + suffix.insert(0, '.'); + suffix + }) + .unwrap_or_default() + )) + }) + .context("no data-directory available on this platform")? + }; + if !path.is_dir() { + bail!("Path '{}' must be a valid directory", path.display()); + } + eprintln!("Using projects from '{}'", path.display()); + Ok(gitbutler_project::Controller::from_path(path)) + } +} + +fn debug_print(this: impl std::fmt::Debug) -> anyhow::Result<()> { + eprintln!("{:#?}", this); + Ok(()) +} diff --git a/crates/gitbutler-cli/src/main.rs b/crates/gitbutler-cli/src/main.rs index 43e9fc685..444594416 100644 --- a/crates/gitbutler-cli/src/main.rs +++ b/crates/gitbutler-cli/src/main.rs @@ -1,10 +1,7 @@ -use anyhow::{Context, Result}; -use std::path::PathBuf; - -use gitbutler_project::Project; +use anyhow::Result; mod args; -use crate::args::snapshot; +use crate::args::{project, snapshot, vbranch}; use args::Args; mod command; @@ -12,24 +9,45 @@ mod command; fn main() -> Result<()> { let args: Args = clap::Parser::parse(); - let project = project_from_path(args.current_dir)?; match args.cmd { - args::Subcommands::Snapshot(snapshot::Platform { cmd }) => match cmd { - Some(snapshot::SubCommands::Restore { snapshot_id }) => { - command::snapshot::restore(project, snapshot_id) + args::Subcommands::Branch(vbranch::Platform { cmd }) => { + let project = command::prepare::project_from_path(args.current_dir)?; + match cmd { + Some(vbranch::SubCommands::Create { name }) => { + command::vbranch::create(project, name) + } + None => command::vbranch::list(project), + } + } + args::Subcommands::Project(project::Platform { + app_data_dir, + app_suffix, + cmd, + }) => match cmd { + Some(project::SubCommands::SwitchToIntegration { remote_ref_name }) => { + let project = command::prepare::project_from_path(args.current_dir)?; + command::project::switch_to_integration(project, remote_ref_name) + } + Some(project::SubCommands::Add { + switch_to_integration, + path, + }) => { + let ctrl = command::prepare::project_controller(app_suffix, app_data_dir)?; + command::project::add(ctrl, path, switch_to_integration) + } + None => { + let ctrl = command::prepare::project_controller(app_suffix, app_data_dir)?; + command::project::list(ctrl) } - None => command::snapshot::list(project), }, + args::Subcommands::Snapshot(snapshot::Platform { cmd }) => { + let project = command::prepare::project_from_path(args.current_dir)?; + match cmd { + Some(snapshot::SubCommands::Restore { snapshot_id }) => { + command::snapshot::restore(project, snapshot_id) + } + None => command::snapshot::list(project), + } + } } } - -fn project_from_path(path: PathBuf) -> Result { - let worktree_dir = gix::discover(path)? - .work_dir() - .context("Bare repositories aren't supported")? - .to_owned(); - Ok(Project { - path: worktree_dir, - ..Default::default() - }) -} diff --git a/crates/gitbutler-git/Cargo.toml b/crates/gitbutler-git/Cargo.toml index 460d5d3dd..e4e2f6e77 100644 --- a/crates/gitbutler-git/Cargo.toml +++ b/crates/gitbutler-git/Cargo.toml @@ -37,7 +37,7 @@ tokio = { workspace = true, optional = true, features = [ ] } uuid = { workspace = true, features = ["v4", "fast-rng"] } rand = "0.8.5" -futures = "0.3.30" +futures.workspace = true sysinfo = "0.30.13" gix-path = "0.10.9" diff --git a/crates/gitbutler-tauri/Cargo.toml b/crates/gitbutler-tauri/Cargo.toml index a55733bd3..22c87fff4 100644 --- a/crates/gitbutler-tauri/Cargo.toml +++ b/crates/gitbutler-tauri/Cargo.toml @@ -27,7 +27,7 @@ backtrace = { version = "0.3.72", optional = true } console-subscriber = "0.3.0" dirs = "5.0.1" fslock.workspace = true -futures = "0.3" +futures.workspace = true git2.workspace = true once_cell = "1.19" reqwest = { version = "0.12.4", features = ["json"] } diff --git a/crates/gitbutler-testsupport/src/lib.rs b/crates/gitbutler-testsupport/src/lib.rs index 0fbbceaef..1a5a36184 100644 --- a/crates/gitbutler-testsupport/src/lib.rs +++ b/crates/gitbutler-testsupport/src/lib.rs @@ -88,7 +88,9 @@ pub mod read_only { path.is_file(), "Expecting driver to be located at {path:?} - we also assume a certain crate location" ); - path + path.canonicalize().expect( + "canonicalization works as the CWD is valid and there are no symlinks to resolve", + ) }); /// Execute the script at `script_name.sh` (assumed to be located in `tests/fixtures/`) diff --git a/crates/gitbutler-watcher/Cargo.toml b/crates/gitbutler-watcher/Cargo.toml index b8321c48c..61951ac87 100644 --- a/crates/gitbutler-watcher/Cargo.toml +++ b/crates/gitbutler-watcher/Cargo.toml @@ -14,7 +14,7 @@ gitbutler-sync.workspace = true gitbutler-oplog.workspace = true thiserror.workspace = true anyhow = "1.0.86" -futures = "0.3.30" +futures.workspace = true tokio = { workspace = true, features = ["macros"] } tokio-util = "0.7.11" tracing = "0.1.40"