From 093795bd0ba476404e55e5a915d5a2a4a31dece0 Mon Sep 17 00:00:00 2001 From: Sebastian Thiel Date: Sat, 27 Jul 2024 15:37:28 +0200 Subject: [PATCH 1/4] first simple test for `list_branches` and basic read-only test framework --- .gitignore | 3 + Cargo.lock | 129 +++++++++++++++++- crates/gitbutler-branch-actions/Cargo.toml | 4 - .../tests/fixtures/for-listing.sh | 18 +++ .../tests/virtual_branches/list.rs | 37 +++++ .../tests/virtual_branches/mod.rs | 1 + crates/gitbutler-repo/Cargo.toml | 2 +- crates/gitbutler-testsupport/Cargo.toml | 2 + crates/gitbutler-testsupport/src/lib.rs | 70 ++++++++++ 9 files changed, 254 insertions(+), 12 deletions(-) create mode 100644 crates/gitbutler-branch-actions/tests/fixtures/for-listing.sh create mode 100644 crates/gitbutler-branch-actions/tests/virtual_branches/list.rs diff --git a/.gitignore b/.gitignore index c2222eadec..0baefbbbec 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,8 @@ # will have compiled rust files and executables target/ +generated-archives/ +generated-do-not-edit/ + # editors .idea diff --git a/Cargo.lock b/Cargo.lock index 3793d779e6..57e2c2dde2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1033,6 +1033,21 @@ dependencies = [ "libc", ] +[[package]] +name = "crc" +version = "3.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69e6e4d7b33a94f0991c26729976b10ebde1d34c3ee82408fb536164fa10d636" +dependencies = [ + "crc-catalog", +] + +[[package]] +name = "crc-catalog" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19d374276b40fb8bbdee95aef7c7fa6b5316ec764510eb64b8dd0e2ed0d7e7f5" + [[package]] name = "crc32fast" version = "1.4.2" @@ -1600,6 +1615,12 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "fs_extra" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" + [[package]] name = "fsevent-sys" version = "4.1.0" @@ -2376,8 +2397,10 @@ dependencies = [ "gitbutler-storage", "gitbutler-url", "gitbutler-user", + "gix-testtools", "keyring", "once_cell", + "parking_lot 0.12.3", "serde_json", "tempfile", ] @@ -2448,7 +2471,7 @@ dependencies = [ "gix-date", "gix-diff", "gix-dir", - "gix-discover", + "gix-discover 0.33.0", "gix-features", "gix-filter", "gix-fs", @@ -2466,7 +2489,7 @@ dependencies = [ "gix-path", "gix-pathspec", "gix-prompt", - "gix-ref", + "gix-ref 0.45.0", "gix-refspec", "gix-revision", "gix-revwalk", @@ -2570,7 +2593,7 @@ dependencies = [ "gix-features", "gix-glob", "gix-path", - "gix-ref", + "gix-ref 0.45.0", "gix-sec", "memchr", "once_cell", @@ -2641,7 +2664,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0c975679aa00dd2d757bfd3ddb232e8a188c0094c3306400575a0813858b1365" dependencies = [ "bstr", - "gix-discover", + "gix-discover 0.33.0", "gix-fs", "gix-ignore", "gix-index", @@ -2654,6 +2677,22 @@ dependencies = [ "thiserror", ] +[[package]] +name = "gix-discover" +version = "0.32.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc27c699b63da66b50d50c00668bc0b7e90c3a382ef302865e891559935f3dbf" +dependencies = [ + "bstr", + "dunce", + "gix-fs", + "gix-hash", + "gix-path", + "gix-ref 0.44.1", + "gix-sec", + "thiserror", +] + [[package]] name = "gix-discover" version = "0.33.0" @@ -2665,7 +2704,7 @@ dependencies = [ "gix-fs", "gix-hash", "gix-path", - "gix-ref", + "gix-ref 0.45.0", "gix-sec", "thiserror", ] @@ -2956,6 +2995,28 @@ dependencies = [ "thiserror", ] +[[package]] +name = "gix-ref" +version = "0.44.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3394a2997e5bc6b22ebc1e1a87b41eeefbcfcff3dbfa7c4bd73cb0ac8f1f3e2e" +dependencies = [ + "gix-actor", + "gix-date", + "gix-features", + "gix-fs", + "gix-hash", + "gix-lock", + "gix-object", + "gix-path", + "gix-tempfile", + "gix-utils", + "gix-validate", + "memmap2", + "thiserror", + "winnow 0.6.13", +] + [[package]] name = "gix-ref" version = "0.45.0" @@ -3057,9 +3118,37 @@ dependencies = [ "libc", "once_cell", "parking_lot 0.12.3", + "signal-hook", + "signal-hook-registry", "tempfile", ] +[[package]] +name = "gix-testtools" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33fd7cd1816d78db635003c9e3fc667a1671689c678de2b92ce7c71ed2d58686" +dependencies = [ + "bstr", + "crc", + "fastrand 2.1.0", + "fs_extra", + "gix-discover 0.32.0", + "gix-fs", + "gix-ignore", + "gix-index", + "gix-lock", + "gix-tempfile", + "gix-worktree", + "io-close", + "is_ci", + "once_cell", + "parking_lot 0.12.3", + "tar", + "tempfile", + "winnow 0.6.13", +] + [[package]] name = "gix-trace" version = "0.1.9" @@ -3782,6 +3871,16 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "io-close" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9cadcf447f06744f8ce713d2d6239bb5bde2c357a452397a9ed90c625da390bc" +dependencies = [ + "libc", + "winapi", +] + [[package]] name = "io-lifetimes" version = "1.0.11" @@ -3818,6 +3917,12 @@ dependencies = [ "once_cell", ] +[[package]] +name = "is_ci" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7655c9839580ee829dfacba1d1278c2b7883e50a277ff7541299489d6bdfdc45" + [[package]] name = "is_terminal_polyfill" version = "1.70.0" @@ -4579,9 +4684,9 @@ dependencies = [ [[package]] name = "openssl" -version = "0.10.66" +version = "0.10.64" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9529f4786b70a3e8c61e11179af17ab6188ad8d0ded78c5529441ed39d4bd9c1" +checksum = "95a0481286a310808298130d22dd1fef0fa571e05a8f44ec801801e84b216b1f" dependencies = [ "bitflags 2.6.0", "cfg-if", @@ -6072,6 +6177,16 @@ dependencies = [ "dirs 5.0.1", ] +[[package]] +name = "signal-hook" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8621587d4798caf8eb44879d42e56b9a93ea5dcd315a6487c357130095b62801" +dependencies = [ + "libc", + "signal-hook-registry", +] + [[package]] name = "signal-hook-registry" version = "1.4.2" diff --git a/crates/gitbutler-branch-actions/Cargo.toml b/crates/gitbutler-branch-actions/Cargo.toml index 5e732d866d..507bd74590 100644 --- a/crates/gitbutler-branch-actions/Cargo.toml +++ b/crates/gitbutler-branch-actions/Cargo.toml @@ -39,10 +39,6 @@ gitbutler-project.workspace = true urlencoding = "2.1.3" reqwest = { version = "0.12.4", features = ["json"] } -[[test]] -name = "virtual" -path = "tests/virtual_branches/mod.rs" - [dev-dependencies] once_cell = "1.19" pretty_assertions = "1.4" diff --git a/crates/gitbutler-branch-actions/tests/fixtures/for-listing.sh b/crates/gitbutler-branch-actions/tests/fixtures/for-listing.sh new file mode 100644 index 0000000000..2e952028af --- /dev/null +++ b/crates/gitbutler-branch-actions/tests/fixtures/for-listing.sh @@ -0,0 +1,18 @@ +#!/usr/bin/env bash +set -eu -o pipefail + +git init remote +(cd remote + echo first > file + git add . && git commit -m "init" +) + +git clone remote single-branch-no-vbranch + +git clone remote single-branch-no-vbranch-multi-remote +(cd single-branch-no-vbranch-multi-remote + git remote add other-origin ../remote + git fetch other-origin +) + + diff --git a/crates/gitbutler-branch-actions/tests/virtual_branches/list.rs b/crates/gitbutler-branch-actions/tests/virtual_branches/list.rs new file mode 100644 index 0000000000..c74943c609 --- /dev/null +++ b/crates/gitbutler-branch-actions/tests/virtual_branches/list.rs @@ -0,0 +1,37 @@ +use anyhow::Result; +use gitbutler_branch_actions::list_branches; +use gitbutler_command_context::ProjectRepository; + +#[test] +fn on_main_single_branch_no_vbranch() -> Result<()> { + let list = list_branches(&project_ctx("single-branch-no-vbranch")?, None)?; + assert_eq!(list.len(), 1); + + let branch = &list[0]; + assert_eq!(branch.name, "main", "short names are used"); + assert_eq!(branch.remotes, &["origin"]); + assert_eq!(branch.virtual_branch, None); + assert_eq!( + branch.authors, + [], + "there is no local commit, so no authors are known" + ); + Ok(()) +} + +#[test] +fn on_main_single_branch_no_vbranch_multiple_remotes() -> Result<()> { + let list = list_branches(&project_ctx("single-branch-no-vbranch-multi-remote")?, None)?; + assert_eq!(list.len(), 1); + + let branch = &list[0]; + assert_eq!(branch.name, "main"); + assert_eq!(branch.remotes, &["other-origin", "origin"]); + assert_eq!(branch.virtual_branch, None); + 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-branch-actions/tests/virtual_branches/mod.rs b/crates/gitbutler-branch-actions/tests/virtual_branches/mod.rs index cc98f1157a..0d8bfabcca 100644 --- a/crates/gitbutler-branch-actions/tests/virtual_branches/mod.rs +++ b/crates/gitbutler-branch-actions/tests/virtual_branches/mod.rs @@ -65,6 +65,7 @@ mod create_virtual_branch_from_branch; mod delete_virtual_branch; mod init; mod insert_blank_commit; +mod list; mod move_commit_file; mod move_commit_to_vbranch; mod oplog; diff --git a/crates/gitbutler-repo/Cargo.toml b/crates/gitbutler-repo/Cargo.toml index 2c36de9176..8feecc6384 100644 --- a/crates/gitbutler-repo/Cargo.toml +++ b/crates/gitbutler-repo/Cargo.toml @@ -9,7 +9,7 @@ publish = false git2.workspace = true anyhow = "1.0.86" bstr = "1.9.1" -tokio = { workspace = true, features = [ "rt-multi-thread", "rt", "macros" ] } +tokio = { workspace = true, features = [ "rt-multi-thread", "rt", "macros", "sync" ] } gitbutler-git.workspace = true tracing = "0.1.40" tempfile = "3.10" diff --git a/crates/gitbutler-testsupport/Cargo.toml b/crates/gitbutler-testsupport/Cargo.toml index 8a385051e2..80364112a9 100644 --- a/crates/gitbutler-testsupport/Cargo.toml +++ b/crates/gitbutler-testsupport/Cargo.toml @@ -16,6 +16,8 @@ git2.workspace = true tempfile = "3.10.1" keyring.workspace = true serde_json = "1.0" +gix-testtools = "0.15.0" +parking_lot.workspace = true gitbutler-branch-actions = { path = "../gitbutler-branch-actions" } gitbutler-repo = { path = "../gitbutler-repo" } gitbutler-command-context.workspace = true diff --git a/crates/gitbutler-testsupport/src/lib.rs b/crates/gitbutler-testsupport/src/lib.rs index cc0d27d9ef..0fbbceaef5 100644 --- a/crates/gitbutler-testsupport/src/lib.rs +++ b/crates/gitbutler-testsupport/src/lib.rs @@ -61,6 +61,76 @@ pub fn init_opts_bare() -> git2::RepositoryInitOptions { opts } +pub mod read_only { + use gitbutler_command_context::ProjectRepository; + use gitbutler_project::{Project, ProjectId}; + use once_cell::sync::Lazy; + use parking_lot::Mutex; + use std::collections::BTreeSet; + use std::path::{Path, PathBuf}; + + 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 + }); + + /// 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 + /// operations and leave relevant files around statically. + /// Use `project_directory` to define where the project is located within the directory containing + /// the output of `script_name`. + /// + /// Returns the project that is strictly for read-only use. + pub fn fixture( + script_name: &str, + project_directory: &str, + ) -> anyhow::Result { + static IS_VALID_PROJECT: Lazy>> = + Lazy::new(|| Mutex::new(Default::default())); + + let root = gix_testtools::scripted_fixture_read_only_with_args( + script_name, + Some(DRIVER.display().to_string()), + ) + .expect("script execution always succeeds"); + + let mut is_valid_guard = IS_VALID_PROJECT.lock(); + let was_inserted = + is_valid_guard.insert((script_name.to_owned(), project_directory.to_owned())); + let project_worktree_dir = root.join(project_directory); + // Assure the project is valid the first time. + let project = if was_inserted { + let tmp = tempfile::TempDir::new()?; + gitbutler_project::Controller::from_path(tmp.path()).add(project_worktree_dir)? + } else { + Project { + id: ProjectId::generate(), + title: project_directory.to_owned(), + path: project_worktree_dir, + ..Default::default() + } + }; + ProjectRepository::open(&project) + } +} + /// 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 5cdbadce4f816afceaa4e84393cf08af6542c1cf Mon Sep 17 00:00:00 2001 From: Sebastian Thiel Date: Sat, 27 Jul 2024 19:20:49 +0200 Subject: [PATCH 2/4] refactor CLI to make it easier to add new commands. Also, remove the pager as there seems to be no need for it just yet, one day it can probably be added when configured neatly. --- Cargo.lock | 50 ++++++----------- crates/gitbutler-cli/Cargo.toml | 6 +- crates/gitbutler-cli/src/args.rs | 46 ++++++++++++++++ crates/gitbutler-cli/src/command.rs | 23 ++++++++ crates/gitbutler-cli/src/main.rs | 85 ++++++++--------------------- 5 files changed, 109 insertions(+), 101 deletions(-) create mode 100644 crates/gitbutler-cli/src/args.rs create mode 100644 crates/gitbutler-cli/src/command.rs diff --git a/Cargo.lock b/Cargo.lock index 57e2c2dde2..404f96a39a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -847,6 +847,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "64acc1846d54c1fe936a78dc189c34e28d3f5afc348403f28ecf53660b9b8462" dependencies = [ "clap_builder", + "clap_derive", ] [[package]] @@ -861,6 +862,18 @@ dependencies = [ "strsim", ] +[[package]] +name = "clap_derive" +version = "4.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2bac35c6dafb060fd4d275d9a4ffae97917c13a6327903a8be2153cd964f7085" +dependencies = [ + "heck 0.5.0", + "proc-macro2", + "quote", + "syn 2.0.68", +] + [[package]] name = "clap_lex" version = "0.7.1" @@ -1411,17 +1424,6 @@ version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5443807d6dff69373d433ab9ef5378ad8df50ca6298caf15de6e52e24aaf54d5" -[[package]] -name = "errno" -version = "0.2.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f639046355ee4f37944e44f60642c6f3a7efa3cf6b78c78a0d989a8ce6c396a1" -dependencies = [ - "errno-dragonfly", - "libc", - "winapi", -] - [[package]] name = "errno" version = "0.3.9" @@ -1432,16 +1434,6 @@ dependencies = [ "windows-sys 0.52.0", ] -[[package]] -name = "errno-dragonfly" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aa68f1b12764fab894d2755d2518754e71b4fd80ecfb822714a1206c2aab39bf" -dependencies = [ - "cc", - "libc", -] - [[package]] name = "event-listener" version = "2.5.3" @@ -2068,7 +2060,7 @@ dependencies = [ "clap", "gitbutler-oplog", "gitbutler-project", - "pager", + "gix", ] [[package]] @@ -4769,16 +4761,6 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b15813163c1d831bf4a13c3610c05c0d03b39feb07f7e09fa234dac9b15aaf39" -[[package]] -name = "pager" -version = "0.16.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2599211a5c97fbbb1061d3dc751fa15f404927e4846e07c643287d6d1f462880" -dependencies = [ - "errno 0.2.8", - "libc", -] - [[package]] name = "pango" version = "0.15.10" @@ -5771,7 +5753,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fea8ca367a3a01fe35e6943c400addf443c0f57670e6ec51196f71a4b8762dd2" dependencies = [ "bitflags 1.3.2", - "errno 0.3.9", + "errno", "io-lifetimes", "libc", "linux-raw-sys 0.3.8", @@ -5785,7 +5767,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "70dc5ec042f7a43c4a73241207cecc9873a06d45debb38b329f8541d85c2730f" dependencies = [ "bitflags 2.6.0", - "errno 0.3.9", + "errno", "libc", "linux-raw-sys 0.4.14", "windows-sys 0.52.0", diff --git a/crates/gitbutler-cli/Cargo.toml b/crates/gitbutler-cli/Cargo.toml index c42bd3a859..c1b70c23f8 100644 --- a/crates/gitbutler-cli/Cargo.toml +++ b/crates/gitbutler-cli/Cargo.toml @@ -12,9 +12,7 @@ path = "src/main.rs" [dependencies] gitbutler-oplog.workspace = true gitbutler-project.workspace = true -clap = "4.5.9" +gix.workspace = true +clap = { version = "4.5.9", features = ["derive"] } anyhow = "1.0.86" chrono = "0.4.10" - -[target."cfg(unix)".dependencies] -pager = "0.16.1" diff --git a/crates/gitbutler-cli/src/args.rs b/crates/gitbutler-cli/src/args.rs new file mode 100644 index 0000000000..4a3b6c1f92 --- /dev/null +++ b/crates/gitbutler-cli/src/args.rs @@ -0,0 +1,46 @@ +use std::path::PathBuf; + +#[derive(Debug, clap::Parser)] +#[clap(name = "gitbutler-cli", about = "A CLI for GitButler", version = option_env!("GIX_VERSION"))] +pub struct Args { + /// Run as if gitbutler-cli was started in PATH instead of the current working directory. + #[clap(short = 'C', long, default_value = ".", value_name = "PATH")] + pub current_dir: PathBuf, + + #[clap(subcommand)] + pub cmd: Subcommands, +} + +#[derive(Debug, clap::Subcommand)] +pub enum Subcommands { + /// List and restore snapshots. + Snapshot(snapshot::Platform), +} + +pub mod snapshot { + #[derive(Debug, clap::Parser)] + pub struct Platform { + #[clap(subcommand)] + pub cmd: Option, + } + + #[derive(Debug, clap::Subcommand)] + pub enum SubCommands { + /// Restores the state of the working direcory as well as virtual branches to a given snapshot. + Restore { + /// The snapshot to restore + snapshot_id: String, + }, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn clap() { + use clap::CommandFactory; + Args::command().debug_assert(); + } +} diff --git a/crates/gitbutler-cli/src/command.rs b/crates/gitbutler-cli/src/command.rs new file mode 100644 index 0000000000..b8be4a9d46 --- /dev/null +++ b/crates/gitbutler-cli/src/command.rs @@ -0,0 +1,23 @@ +pub mod snapshot { + use anyhow::Result; + use gitbutler_oplog::OplogExt; + use gitbutler_project::Project; + + pub fn list(project: Project) -> Result<()> { + let snapshots = project.list_snapshots(100, None)?; + for snapshot in snapshots { + 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.commit_id, details.operation); + } + } + Ok(()) + } + + pub fn restore(project: Project, snapshot_id: String) -> Result<()> { + let _guard = project.try_exclusive_access()?; + project.restore_snapshot(snapshot_id.parse()?)?; + Ok(()) + } +} diff --git a/crates/gitbutler-cli/src/main.rs b/crates/gitbutler-cli/src/main.rs index e589739f5b..43e9fc6858 100644 --- a/crates/gitbutler-cli/src/main.rs +++ b/crates/gitbutler-cli/src/main.rs @@ -1,76 +1,35 @@ -use anyhow::Result; -use gitbutler_oplog::OplogExt; +use anyhow::{Context, Result}; +use std::path::PathBuf; -use clap::{arg, Command}; use gitbutler_project::Project; -#[cfg(not(windows))] -use pager::Pager; -fn cli() -> Command { - Command::new("gitbutler-cli") - .about("A CLI tool for GitButler") - .arg(arg!(-C "Run as if gitbutler-cli was started in instead of the current working directory.")) - .subcommand_required(true) - .arg_required_else_help(true) - .allow_external_subcommands(true) - .subcommand( - Command::new("snapshot") - .about("List and restore snapshots.") - .subcommand(Command::new("restore") - .about("Restores the state of the working direcory as well as virtual branches to a given snapshot.") - .arg(arg!( "The snapshot to restore"))), - ) -} +mod args; +use crate::args::snapshot; +use args::Args; -fn main() -> Result<()> { - #[cfg(not(windows))] - Pager::new().setup(); - let matches = cli().get_matches(); +mod command; - let cwd = std::env::current_dir()?.to_string_lossy().to_string(); - let repo_dir = matches.get_one::("path").unwrap_or(&cwd); +fn main() -> Result<()> { + let args: Args = clap::Parser::parse(); - match matches.subcommand() { - Some(("snapshot", sub_matches)) => match sub_matches.subcommand() { - Some(("restore", sub_matches)) => { - let snapshot_id = sub_matches - .get_one::("SNAPSHOT_ID") - .expect("required"); - restore_snapshot(repo_dir, snapshot_id)?; - } - _ => { - list_snapshots(repo_dir)?; + 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) } + None => command::snapshot::list(project), }, - _ => unreachable!(), } - - Ok(()) } -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.seconds(), 0); - let details = snapshot.details; - if let (Some(ts), Some(details)) = (ts, details) { - println!("{} {} {}", ts, snapshot.commit_id, details.operation); - } - } - Ok(()) -} - -fn restore_snapshot(repo_dir: &str, snapshot_id: &str) -> Result<()> { - let project = project_from_path(repo_dir); - let _guard = project.try_exclusive_access()?; - project.restore_snapshot(snapshot_id.parse()?)?; - Ok(()) -} - -fn project_from_path(repo_dir: &str) -> Project { - Project { - path: std::path::PathBuf::from(repo_dir), +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() - } + }) } From 282519eca56bcc341e4223bd9fc535f54d44556e Mon Sep 17 00:00:00 2001 From: Sebastian Thiel Date: Sat, 27 Jul 2024 19:15:02 +0200 Subject: [PATCH 3/4] add ability to create add projects in CLI and create vbranches This allows actual GitButler setups and more complex tests, which are also added here. --- Cargo.lock | 5 + Cargo.toml | 1 + crates/gitbutler-branch-actions/Cargo.toml | 2 +- .../tests/fixtures/for-listing.sh | 7 + .../tests/virtual_branches/list.rs | 23 +++- crates/gitbutler-cli/Cargo.toml | 7 +- crates/gitbutler-cli/src/args.rs | 64 +++++++++ crates/gitbutler-cli/src/command.rs | 130 ++++++++++++++++++ crates/gitbutler-cli/src/main.rs | 60 +++++--- crates/gitbutler-git/Cargo.toml | 2 +- crates/gitbutler-tauri/Cargo.toml | 2 +- crates/gitbutler-testsupport/src/lib.rs | 4 +- crates/gitbutler-watcher/Cargo.toml | 2 +- 13 files changed, 280 insertions(+), 29 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 404f96a39a..a8845e0f33 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 868038db7b..189870b7de 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 507bd74590..09ba81ea5a 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 2e952028af..6b3515647d 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 c74943c609..3938813110 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 c1b70c23f8..e4e3c42fdc 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 4a3b6c1f92..09c0c893cd 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 b8be4a9d46..9553733846 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 43e9fc6858..4445944169 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 460d5d3dd2..e4e2f6e772 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 a55733bd3f..22c87fff46 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 0fbbceaef5..1a5a361848 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 b8321c48c0..61951ac872 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" From 7c371e5dd4321a1cbce442045d398817687e16e3 Mon Sep 17 00:00:00 2001 From: Sebastian Thiel Date: Sun, 28 Jul 2024 10:46:46 +0200 Subject: [PATCH 4/4] More tests to understand more about the current branch-listing. This also needs the ability to create commits. Also experiment with `async` tauri commands, but without actually making them `async` - nothing actually is so why pretend? Further, assure we get the correct author and committer which helps it pick up the overridden author information when creating a commit. --- Cargo.lock | 2 + crates/gitbutler-branch-actions/src/branch.rs | 29 +++--- crates/gitbutler-branch-actions/src/lib.rs | 2 +- .../gitbutler-branch-actions/src/virtual.rs | 2 + .../tests/fixtures/for-listing.sh | 14 +++ .../tests/virtual_branches/list.rs | 79 +++++++++++++++- crates/gitbutler-cli/Cargo.toml | 1 + crates/gitbutler-cli/src/args.rs | 13 +++ crates/gitbutler-cli/src/command.rs | 90 ++++++++++++++++++- crates/gitbutler-cli/src/main.rs | 6 ++ crates/gitbutler-repo/Cargo.toml | 1 + crates/gitbutler-repo/src/repository.rs | 62 ++++++++----- .../gitbutler-tauri/src/virtual_branches.rs | 3 +- 13 files changed, 262 insertions(+), 42 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index a8845e0f33..010d529bf1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2062,6 +2062,7 @@ dependencies = [ "futures", "gitbutler-branch", "gitbutler-branch-actions", + "gitbutler-diff", "gitbutler-oplog", "gitbutler-project", "gitbutler-reference", @@ -2265,6 +2266,7 @@ dependencies = [ "gitbutler-time", "gitbutler-url", "gitbutler-user", + "gix", "log", "resolve-path", "serde", diff --git a/crates/gitbutler-branch-actions/src/branch.rs b/crates/gitbutler-branch-actions/src/branch.rs index 3255b68777..7d406634e6 100644 --- a/crates/gitbutler-branch-actions/src/branch.rs +++ b/crates/gitbutler-branch-actions/src/branch.rs @@ -13,12 +13,12 @@ use gitbutler_branch::Target; use gitbutler_branch::VirtualBranchesHandle; use gitbutler_command_context::ProjectRepository; +use crate::{VirtualBranch, VirtualBranchesExt}; use gitbutler_reference::normalize_branch_name; +use gitbutler_repo::RepoActionsExt; use serde::Deserialize; use serde::Serialize; -use crate::{VirtualBranch, VirtualBranchesExt}; - /// Returns a list of branches associated with this project. // TODO: Implement pagination for this thing // TODO: The results should be sortedb by updated_at @@ -70,7 +70,7 @@ pub fn list_branches( .list_all_branches()? .into_iter(); - let branches = combine_branches(git_branches, virtual_branches, ctx.repo(), &vb_handle)?; + let branches = combine_branches(git_branches, virtual_branches, ctx, &vb_handle)?; // Apply the filter let branches: Vec = branches .into_iter() @@ -101,9 +101,10 @@ fn matches_all(branch: &BranchListing, filter: &Option) -> fn combine_branches( mut group_branches: Vec, virtual_branches: impl Iterator, - repo: &git2::Repository, + ctx: &ProjectRepository, vb_handle: &VirtualBranchesHandle, ) -> Result> { + let repo = ctx.repo(); for branch in virtual_branches { group_branches.push(GroupBranch::Virtual(branch)); } @@ -123,11 +124,7 @@ fn combine_branches( groups.insert(identity, vec![branch]); } } - let config = repo.config()?; - let local_author = Author { - name: config.get_string("user.name").ok(), - email: config.get_string("user.email").ok(), - }; + let (local_author, _committer) = ctx.signatures()?; // Convert to Branch entries for the API response, filtering out any errors let branches: Vec = groups @@ -157,7 +154,7 @@ fn branch_group_to_branch( identity: Option, group_branches: Vec<&GroupBranch>, repo: &git2::Repository, - local_author: &Author, + local_author: &git2::Signature, ) -> Result { let virtual_branch = group_branches .iter() @@ -233,13 +230,13 @@ fn branch_group_to_branch( commits.push(commit); } - let mut own_branch = commits - .iter() - .any(|commit| local_author == &commit.author().into()); // If there are no commits (i.e. virtual branch only) it is considered the users own - if commits.is_empty() { - own_branch = true; - } + let own_branch = commits.is_empty() + || commits.iter().any(|commit| { + let commit_author = commit.author(); + local_author.name_bytes() == commit_author.name_bytes() + && local_author.email_bytes() == commit_author.email_bytes() + }); let last_modified_ms = max( last_commit_time_ms as u128, diff --git a/crates/gitbutler-branch-actions/src/lib.rs b/crates/gitbutler-branch-actions/src/lib.rs index 99c1bbc9ad..92306e9dea 100644 --- a/crates/gitbutler-branch-actions/src/lib.rs +++ b/crates/gitbutler-branch-actions/src/lib.rs @@ -42,4 +42,4 @@ mod branch; mod commit; mod hunk; -pub use branch::{list_branches, BranchListing, BranchListingFilter}; +pub use branch::{list_branches, Author, BranchListing, BranchListingFilter}; diff --git a/crates/gitbutler-branch-actions/src/virtual.rs b/crates/gitbutler-branch-actions/src/virtual.rs index b99c36229a..83b18183e7 100644 --- a/crates/gitbutler-branch-actions/src/virtual.rs +++ b/crates/gitbutler-branch-actions/src/virtual.rs @@ -61,6 +61,8 @@ pub struct VirtualBranch { pub upstream: Option, // the upstream branch where this branch pushes to, if any pub upstream_name: Option, // the upstream branch where this branch will push to on next push pub base_current: bool, // is this vbranch based on the current base branch? if false, this needs to be manually merged with conflicts + /// The hunks (as `[(file, [hunks])]`) which are uncommitted but assigned to this branch. + /// This makes them committable. pub ownership: BranchOwnershipClaims, pub updated_at: u128, pub selected_for_changes: bool, diff --git a/crates/gitbutler-branch-actions/tests/fixtures/for-listing.sh b/crates/gitbutler-branch-actions/tests/fixtures/for-listing.sh index 6b3515647d..7c4cdb3ab5 100644 --- a/crates/gitbutler-branch-actions/tests/fixtures/for-listing.sh +++ b/crates/gitbutler-branch-actions/tests/fixtures/for-listing.sh @@ -10,6 +10,11 @@ git init remote git clone remote single-branch-no-vbranch +git clone remote single-branch-no-vbranch-one-commit +(cd single-branch-no-vbranch-one-commit + echo change >> file && git add . && git commit -m "local change" +) + git clone remote single-branch-no-vbranch-multi-remote (cd single-branch-no-vbranch-multi-remote git remote add other-origin ../remote @@ -23,3 +28,12 @@ git clone remote one-vbranch-on-integration $CLI branch create virtual ) +git clone remote one-vbranch-on-integration-one-commit +(cd one-vbranch-on-integration-one-commit + $CLI project add --switch-to-integration "$(git rev-parse --symbolic-full-name @{u})" + $CLI branch create virtual + echo change >> file + echo in-index > new && git add new + $CLI branch commit virtual -m "virtual branch change in index and worktree" +) + diff --git a/crates/gitbutler-branch-actions/tests/virtual_branches/list.rs b/crates/gitbutler-branch-actions/tests/virtual_branches/list.rs index 3938813110..e225eec8ce 100644 --- a/crates/gitbutler-branch-actions/tests/virtual_branches/list.rs +++ b/crates/gitbutler-branch-actions/tests/virtual_branches/list.rs @@ -1,9 +1,10 @@ use anyhow::Result; -use gitbutler_branch_actions::list_branches; +use gitbutler_branch_actions::{list_branches, Author}; use gitbutler_command_context::ProjectRepository; #[test] fn on_main_single_branch_no_vbranch() -> Result<()> { + init_env(); let list = list_branches(&project_ctx("single-branch-no-vbranch")?, None)?; assert_eq!(list.len(), 1); @@ -11,16 +12,19 @@ fn on_main_single_branch_no_vbranch() -> Result<()> { assert_eq!(branch.name, "main", "short names are used"); assert_eq!(branch.remotes, ["origin"]); assert_eq!(branch.virtual_branch, None); + assert_eq!(branch.number_of_commits, 0); assert_eq!( branch.authors, [], "there is no local commit, so no authors are known" ); + assert!(branch.own_branch); Ok(()) } #[test] fn on_main_single_branch_no_vbranch_multiple_remotes() -> Result<()> { + init_env(); let list = list_branches(&project_ctx("single-branch-no-vbranch-multi-remote")?, None)?; assert_eq!(list.len(), 1); @@ -28,18 +32,45 @@ fn on_main_single_branch_no_vbranch_multiple_remotes() -> Result<()> { assert_eq!(branch.name, "main"); assert_eq!(branch.remotes, ["other-origin", "origin"]); assert_eq!(branch.virtual_branch, None); + assert_eq!(branch.number_of_commits, 0); assert_eq!(branch.authors, []); + assert!(branch.own_branch); + Ok(()) +} + +#[test] +fn on_main_single_branch_no_vbranch_one_commit() -> Result<()> { + init_env(); + let list = list_branches(&project_ctx("single-branch-no-vbranch-one-commit")?, None)?; + assert_eq!(list.len(), 1); + + let branch = &list[0]; + assert_eq!(branch.name, "main"); + assert_eq!(branch.remotes, ["origin"]); + assert_eq!(branch.virtual_branch, None); + assert_eq!( + branch.number_of_commits, 0, + "local-only commits aren't detected" + ); + assert_eq!( + branch.authors, + [], + "and thus there is no ownership information" + ); + assert!(branch.own_branch); Ok(()) } #[test] fn one_vbranch_on_integration() -> Result<()> { + init_env(); 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.number_of_commits, 0); assert_eq!( branch .virtual_branch @@ -48,9 +79,55 @@ fn one_vbranch_on_integration() -> Result<()> { Some("virtual") ); assert_eq!(branch.authors, []); + assert!(branch.own_branch, "zero commits means user owns the branch"); + Ok(()) +} + +#[test] +fn one_vbranch_on_integration_one_commit() -> Result<()> { + init_env(); + let list = list_branches(&project_ctx("one-vbranch-on-integration-one-commit")?, 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.number_of_commits, 1, "one commit created on vbranch"); + assert_eq!(branch.authors, [default_author()]); + assert!(branch.own_branch); Ok(()) } +/// This function affects all tests, but those who care should just call it, assuming +/// they all care for the same default value. +/// If not, they should be placed in their own integration test or run with `#[serial_test:serial]`. +fn init_env() { + for (name, value) in [ + ("GIT_AUTHOR_DATE", "2000-01-01 00:00:00 +0000"), + ("GIT_AUTHOR_EMAIL", "author@example.com"), + ("GIT_AUTHOR_NAME", "author"), + ("GIT_COMMITTER_DATE", "2000-01-02 00:00:00 +0000"), + ("GIT_COMMITTER_EMAIL", "committer@example.com"), + ("GIT_COMMITTER_NAME", "committer"), + ] { + std::env::set_var(name, value); + } +} + +fn default_author() -> Author { + Author { + name: Some("author".into()), + email: Some("author@example.com".into()), + } +} + 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 e4e3c42fdc..21b3d212d2 100644 --- a/crates/gitbutler-cli/Cargo.toml +++ b/crates/gitbutler-cli/Cargo.toml @@ -15,6 +15,7 @@ gitbutler-project.workspace = true gitbutler-reference.workspace = true gitbutler-branch-actions.workspace = true gitbutler-branch.workspace = true +gitbutler-diff.workspace = true gix.workspace = true futures.workspace = true dirs-next = "2.0.0" diff --git a/crates/gitbutler-cli/src/args.rs b/crates/gitbutler-cli/src/args.rs index 09c0c893cd..16dbde189a 100644 --- a/crates/gitbutler-cli/src/args.rs +++ b/crates/gitbutler-cli/src/args.rs @@ -33,6 +33,19 @@ pub mod vbranch { #[derive(Debug, clap::Subcommand)] pub enum SubCommands { + /// Make the named branch the default so all worktree or index changes are associated with it automatically. + SetDefault { + /// The name of the new default virtual branch. + name: String, + }, + /// Create a new commit to named virtual branch with all changes currently in the worktree or staging area assigned to it. + Commit { + /// The commit message + #[clap(short = 'm', long)] + message: String, + /// The name of the virtual to commit all staged and unstaged changes to. + name: String, + }, /// Create a new virtual branch Create { /// The name of the virtual branch to create diff --git a/crates/gitbutler-cli/src/command.rs b/crates/gitbutler-cli/src/command.rs index 9553733846..d6a5cf0bdc 100644 --- a/crates/gitbutler-cli/src/command.rs +++ b/crates/gitbutler-cli/src/command.rs @@ -1,11 +1,15 @@ pub mod vbranch { use crate::command::debug_print; + use anyhow::bail; + use anyhow::Result; use futures::executor::block_on; - use gitbutler_branch::{BranchCreateRequest, VirtualBranchesHandle}; + use gitbutler_branch::{ + Branch, BranchCreateRequest, BranchUpdateRequest, VirtualBranchesHandle, + }; use gitbutler_branch_actions::VirtualBranchActions; use gitbutler_project::Project; - pub fn list(project: Project) -> anyhow::Result<()> { + pub fn list(project: Project) -> Result<()> { let branches = VirtualBranchesHandle::new(project.gb_dir()).list_all_branches()?; for vbranch in branches { println!( @@ -21,7 +25,7 @@ pub mod vbranch { Ok(()) } - pub fn create(project: Project, branch_name: String) -> anyhow::Result<()> { + pub fn create(project: Project, branch_name: String) -> Result<()> { debug_print(block_on(VirtualBranchActions.create_virtual_branch( &project, &BranchCreateRequest { @@ -30,6 +34,86 @@ pub mod vbranch { }, ))?) } + + pub fn set_default(project: Project, branch_name: String) -> Result<()> { + let branch = branch_by_name(&project, &branch_name)?; + block_on(VirtualBranchActions.update_virtual_branch( + &project, + BranchUpdateRequest { + id: branch.id, + name: None, + notes: None, + ownership: None, + order: None, + upstream: None, + selected_for_changes: Some(true), + allow_rebasing: None, + }, + )) + } + + pub fn commit(project: Project, branch_name: String, message: String) -> Result<()> { + let branch = branch_by_name(&project, &branch_name)?; + let (info, skipped) = block_on(VirtualBranchActions.list_virtual_branches(&project))?; + + if !skipped.is_empty() { + eprintln!( + "{} files could not be processed (binary or large size)", + skipped.len() + ) + } + + let populated_branch = info + .iter() + .find(|b| b.id == branch.id) + .expect("A populated branch exists for a branch we can list"); + if populated_branch.ownership.claims.is_empty() { + bail!( + "Branch '{branch_name}' has no change to commit{hint}", + hint = { + let candidate_names = info + .iter() + .filter_map(|b| (!b.ownership.claims.is_empty()).then_some(b.name.as_str())) + .collect::>(); + let mut candidates = candidate_names.join(", "); + if !candidate_names.is_empty() { + candidates = format!( + ". {candidates} {have} changes.", + have = if candidate_names.len() == 1 { + "has" + } else { + "have" + } + ) + }; + candidates + } + ) + } + + let run_hooks = false; + debug_print(block_on(VirtualBranchActions.create_commit( + &project, + branch.id, + &message, + Some(&populated_branch.ownership), + run_hooks, + ))?) + } + + pub fn branch_by_name(project: &Project, name: &str) -> Result { + let mut found: Vec<_> = VirtualBranchesHandle::new(project.gb_dir()) + .list_all_branches()? + .into_iter() + .filter(|b| b.name == name) + .collect(); + if found.is_empty() { + bail!("No virtual branch named '{name}'"); + } else if found.len() > 1 { + bail!("Found more than one virtual branch named '{name}'"); + } + Ok(found.pop().expect("present")) + } } pub mod project { diff --git a/crates/gitbutler-cli/src/main.rs b/crates/gitbutler-cli/src/main.rs index 4445944169..dbd017114f 100644 --- a/crates/gitbutler-cli/src/main.rs +++ b/crates/gitbutler-cli/src/main.rs @@ -13,6 +13,12 @@ fn main() -> Result<()> { args::Subcommands::Branch(vbranch::Platform { cmd }) => { let project = command::prepare::project_from_path(args.current_dir)?; match cmd { + Some(vbranch::SubCommands::SetDefault { name }) => { + command::vbranch::set_default(project, name) + } + Some(vbranch::SubCommands::Commit { message, name }) => { + command::vbranch::commit(project, name, message) + } Some(vbranch::SubCommands::Create { name }) => { command::vbranch::create(project, name) } diff --git a/crates/gitbutler-repo/Cargo.toml b/crates/gitbutler-repo/Cargo.toml index 8feecc6384..39a1bd0605 100644 --- a/crates/gitbutler-repo/Cargo.toml +++ b/crates/gitbutler-repo/Cargo.toml @@ -7,6 +7,7 @@ publish = false [dependencies] git2.workspace = true +gix.workspace = true anyhow = "1.0.86" bstr = "1.9.1" tokio = { workspace = true, features = [ "rt-multi-thread", "rt", "macros", "sync" ] } diff --git a/crates/gitbutler-repo/src/repository.rs b/crates/gitbutler-repo/src/repository.rs index 27b0de77dd..345a6b6fa3 100644 --- a/crates/gitbutler-repo/src/repository.rs +++ b/crates/gitbutler-repo/src/repository.rs @@ -1,8 +1,11 @@ use std::str::FromStr; use anyhow::{anyhow, Context, Result}; - -use gitbutler_branch::{Branch, BranchId}; +use bstr::ByteSlice; +use gitbutler_branch::{ + Branch, BranchId, GITBUTLER_INTEGRATION_COMMIT_AUTHOR_EMAIL, + GITBUTLER_INTEGRATION_COMMIT_AUTHOR_NAME, +}; use gitbutler_command_context::ProjectRepository; use gitbutler_commit::commit_headers::CommitHeadersV2; use gitbutler_error::error::Code; @@ -45,6 +48,7 @@ pub trait RepoActionsExt { branch_name: &str, askpass: Option>, ) -> Result<()>; + fn signatures(&self) -> Result<(git2::Signature, git2::Signature)>; } impl RepoActionsExt for ProjectRepository { @@ -229,7 +233,7 @@ impl RepoActionsExt for ProjectRepository { parents: &[&git2::Commit], commit_headers: Option, ) -> Result { - let (author, committer) = signatures(self).context("failed to get signatures")?; + let (author, committer) = self.signatures().context("failed to get signatures")?; self.repo() .commit_with_signature( None, @@ -424,25 +428,43 @@ impl RepoActionsExt for ProjectRepository { Err(anyhow!("authentication failed")).context(Code::ProjectGitAuth) } -} - -fn signatures(project_repo: &ProjectRepository) -> Result<(git2::Signature, git2::Signature)> { - let config: Config = project_repo.repo().into(); - - let author = match (config.user_name()?, config.user_email()?) { - (None, Some(email)) => git2::Signature::now(&email, &email)?, - (Some(name), None) => git2::Signature::now(&name, &format!("{}@example.com", &name))?, - (Some(name), Some(email)) => git2::Signature::now(&name, &email)?, - _ => git2::Signature::now("GitButler", "gitbutler@gitbutler.com")?, - }; - let comitter = if config.user_real_comitter()? { - author.clone() - } else { - git2::Signature::now("GitButler", "gitbutler@gitbutler.com")? - }; + fn signatures(&self) -> Result<(git2::Signature, git2::Signature)> { + let repo = gix::open(self.repo().path())?; + + let default_actor = gix::actor::SignatureRef { + name: GITBUTLER_INTEGRATION_COMMIT_AUTHOR_NAME.into(), + email: GITBUTLER_INTEGRATION_COMMIT_AUTHOR_EMAIL.into(), + time: Default::default(), + }; + let author = repo.author().transpose()?.unwrap_or(default_actor); + let config: Config = self.repo().into(); + let committer = if config.user_real_comitter()? { + repo.committer().transpose()?.unwrap_or(default_actor) + } else { + default_actor + }; + + Ok(( + actor_to_git2_signature(author)?, + actor_to_git2_signature(committer)?, + )) + } +} - Ok((author, comitter)) +fn actor_to_git2_signature( + actor: gix::actor::SignatureRef<'_>, +) -> Result> { + Ok(git2::Signature::now( + actor + .name + .to_str() + .with_context(|| format!("Could not process actor name: {}", actor.name))?, + actor + .email + .to_str() + .with_context(|| format!("Could not process actor email: {}", actor.email))?, + )?) } type OidFilter = dyn Fn(&git2::Commit) -> Result; diff --git a/crates/gitbutler-tauri/src/virtual_branches.rs b/crates/gitbutler-tauri/src/virtual_branches.rs index 27f324c93a..eb771f14b0 100644 --- a/crates/gitbutler-tauri/src/virtual_branches.rs +++ b/crates/gitbutler-tauri/src/virtual_branches.rs @@ -1,6 +1,7 @@ pub mod commands { use crate::error::Error; use anyhow::{anyhow, Context}; + use futures::executor::block_on; use gitbutler_branch::BranchOwnershipClaims; use gitbutler_branch::{BranchCreateRequest, BranchId, BranchUpdateRequest}; use gitbutler_branch_actions::{BaseBranch, BranchListing}; @@ -40,7 +41,7 @@ pub mod commands { let oid = VirtualBranchActions .create_commit(&project, branch, message, ownership.as_ref(), run_hooks) .await?; - emit_vbranches(&windows, project_id).await; + block_on(emit_vbranches(&windows, project_id)); Ok(oid.to_string()) }