Merge pull request #4509 from Byron/git2-to-gix

This commit is contained in:
Kiril Videlov 2024-07-28 17:34:42 +02:00 committed by GitHub
commit 2d0fa99452
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
22 changed files with 877 additions and 156 deletions

3
.gitignore vendored
View File

@ -1,5 +1,8 @@
# will have compiled rust files and executables
target/
generated-archives/
generated-do-not-edit/
# editors
.idea

186
Cargo.lock generated
View File

@ -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"
@ -1033,6 +1046,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"
@ -1396,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"
@ -1417,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"
@ -1600,6 +1607,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"
@ -2045,9 +2058,15 @@ dependencies = [
"anyhow",
"chrono",
"clap",
"dirs-next",
"futures",
"gitbutler-branch",
"gitbutler-branch-actions",
"gitbutler-diff",
"gitbutler-oplog",
"gitbutler-project",
"pager",
"gitbutler-reference",
"gix",
]
[[package]]
@ -2247,6 +2266,7 @@ dependencies = [
"gitbutler-time",
"gitbutler-url",
"gitbutler-user",
"gix",
"log",
"resolve-path",
"serde",
@ -2376,8 +2396,10 @@ dependencies = [
"gitbutler-storage",
"gitbutler-url",
"gitbutler-user",
"gix-testtools",
"keyring",
"once_cell",
"parking_lot 0.12.3",
"serde_json",
"tempfile",
]
@ -2448,7 +2470,7 @@ dependencies = [
"gix-date",
"gix-diff",
"gix-dir",
"gix-discover",
"gix-discover 0.33.0",
"gix-features",
"gix-filter",
"gix-fs",
@ -2466,7 +2488,7 @@ dependencies = [
"gix-path",
"gix-pathspec",
"gix-prompt",
"gix-ref",
"gix-ref 0.45.0",
"gix-refspec",
"gix-revision",
"gix-revwalk",
@ -2570,7 +2592,7 @@ dependencies = [
"gix-features",
"gix-glob",
"gix-path",
"gix-ref",
"gix-ref 0.45.0",
"gix-sec",
"memchr",
"once_cell",
@ -2641,7 +2663,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 +2676,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 +2703,7 @@ dependencies = [
"gix-fs",
"gix-hash",
"gix-path",
"gix-ref",
"gix-ref 0.45.0",
"gix-sec",
"thiserror",
]
@ -2956,6 +2994,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 +3117,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 +3870,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 +3916,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 +4683,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",
@ -4664,16 +4768,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"
@ -5666,7 +5760,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",
@ -5680,7 +5774,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",
@ -6072,6 +6166,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"

View File

@ -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" }

View File

@ -32,17 +32,13 @@ 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
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"

View File

@ -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<BranchListing> = branches
.into_iter()
@ -101,9 +101,10 @@ fn matches_all(branch: &BranchListing, filter: &Option<BranchListingFilter>) ->
fn combine_branches(
mut group_branches: Vec<GroupBranch>,
virtual_branches: impl Iterator<Item = GitButlerBranch>,
repo: &git2::Repository,
ctx: &ProjectRepository,
vb_handle: &VirtualBranchesHandle,
) -> Result<Vec<BranchListing>> {
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<BranchListing> = groups
@ -157,7 +154,7 @@ fn branch_group_to_branch(
identity: Option<String>,
group_branches: Vec<&GroupBranch>,
repo: &git2::Repository,
local_author: &Author,
local_author: &git2::Signature,
) -> Result<BranchListing> {
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,

View File

@ -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};

View File

@ -61,6 +61,8 @@ pub struct VirtualBranch {
pub upstream: Option<RemoteBranch>, // the upstream branch where this branch pushes to, if any
pub upstream_name: Option<String>, // 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,

View File

@ -0,0 +1,39 @@
#!/usr/bin/env bash
set -eu -o pipefail
CLI=${1:?The first argument is the GitButler CLI}
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-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
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
)
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"
)

View File

@ -0,0 +1,133 @@
use anyhow::Result;
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);
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.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);
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.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
.as_ref()
.map(|v| v.given_name.as_str()),
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<ProjectRepository> {
gitbutler_testsupport::read_only::fixture("for-listing.sh", name)
}

View File

@ -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;

View File

@ -12,9 +12,13 @@ path = "src/main.rs"
[dependencies]
gitbutler-oplog.workspace = true
gitbutler-project.workspace = true
clap = "4.5.9"
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"
clap = { version = "4.5.9", features = ["derive", "env"] }
anyhow = "1.0.86"
chrono = "0.4.10"
[target."cfg(unix)".dependencies]
pager = "0.16.1"

View File

@ -0,0 +1,123 @@
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 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<SubCommands>,
}
#[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
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<PathBuf>,
/// 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<String>,
#[clap(subcommand)]
pub cmd: Option<SubCommands>,
}
#[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<RemoteRefname>,
/// 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 {
#[clap(subcommand)]
pub cmd: Option<SubCommands>,
}
#[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();
}
}

View File

@ -0,0 +1,237 @@
pub mod vbranch {
use crate::command::debug_print;
use anyhow::bail;
use anyhow::Result;
use futures::executor::block_on;
use gitbutler_branch::{
Branch, BranchCreateRequest, BranchUpdateRequest, VirtualBranchesHandle,
};
use gitbutler_branch_actions::VirtualBranchActions;
use gitbutler_project::Project;
pub fn list(project: Project) -> 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) -> Result<()> {
debug_print(block_on(VirtualBranchActions.create_virtual_branch(
&project,
&BranchCreateRequest {
name: Some(branch_name),
..Default::default()
},
))?)
}
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::<Vec<_>>();
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<Branch> {
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 {
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<RemoteRefname>,
) -> 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;
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(())
}
}
pub mod prepare {
use anyhow::{bail, Context};
use gitbutler_project::Project;
use std::path::PathBuf;
pub fn project_from_path(path: PathBuf) -> anyhow::Result<Project> {
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<String>,
app_data_dir: Option<PathBuf>,
) -> anyhow::Result<gitbutler_project::Controller> {
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(())
}

View File

@ -1,76 +1,59 @@
use anyhow::Result;
use gitbutler_oplog::OplogExt;
use clap::{arg, Command};
use gitbutler_project::Project;
#[cfg(not(windows))]
use pager::Pager;
mod args;
use crate::args::{project, snapshot, vbranch};
use args::Args;
fn cli() -> Command {
Command::new("gitbutler-cli")
.about("A CLI tool for GitButler")
.arg(arg!(-C <path> "Run as if gitbutler-cli was started in <path> 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!(<SNAPSHOT_ID> "The snapshot to restore"))),
)
}
mod command;
fn main() -> Result<()> {
#[cfg(not(windows))]
Pager::new().setup();
let matches = cli().get_matches();
let args: Args = clap::Parser::parse();
let cwd = std::env::current_dir()?.to_string_lossy().to_string();
let repo_dir = matches.get_one::<String>("path").unwrap_or(&cwd);
match matches.subcommand() {
Some(("snapshot", sub_matches)) => match sub_matches.subcommand() {
Some(("restore", sub_matches)) => {
let snapshot_id = sub_matches
.get_one::<String>("SNAPSHOT_ID")
.expect("required");
restore_snapshot(repo_dir, snapshot_id)?;
match args.cmd {
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)
}
None => command::vbranch::list(project),
}
_ => {
list_snapshots(repo_dir)?;
}
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)
}
},
_ => 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);
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),
}
}
}
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),
..Default::default()
}
}

View File

@ -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"

View File

@ -7,9 +7,10 @@ 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" ] }
tokio = { workspace = true, features = [ "rt-multi-thread", "rt", "macros", "sync" ] }
gitbutler-git.workspace = true
tracing = "0.1.40"
tempfile = "3.10"

View File

@ -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<Option<BranchId>>,
) -> 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<CommitHeadersV2>,
) -> Result<git2::Oid> {
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(&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)?,
))
}
}
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")?
};
Ok((author, comitter))
fn actor_to_git2_signature(
actor: gix::actor::SignatureRef<'_>,
) -> Result<git2::Signature<'static>> {
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<bool>;

View File

@ -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"] }

View File

@ -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())
}

View File

@ -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

View File

@ -61,6 +61,78 @@ 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<PathBuf> = 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",
)
});
/// Execute the script at `script_name.sh` (assumed to be located in `tests/fixtures/<script_name>`)
/// 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<ProjectRepository> {
static IS_VALID_PROJECT: Lazy<Mutex<BTreeSet<(String, String)>>> =
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

View File

@ -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"