Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
322 changes: 70 additions & 252 deletions Cargo.lock

Large diffs are not rendered by default.

16 changes: 8 additions & 8 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -12,24 +12,24 @@ repository = "https://github.com/lettertwo/git-workon"
rust-version = "1.68.2"

[workspace.dependencies]
assert_cmd = { version = "2.2.0", features = ["color", "color-auto"] }
assert_cmd = { version = "2.2.2", features = ["color", "color-auto"] }
assert_fs = { version = "1.1.3", features = ["color", "color-auto"] }
clap = { version = "4.6.0", features = [
clap = { version = "4.6.1", features = [
"derive",
"color",
"wrap_help",
"unicode",
"env",
] }
clap-verbosity-flag = "3.0.4"
clap_complete = { version = "4.6.0", features = ["unstable-dynamic"] }
clap_mangen = "0.2.33"
clap_complete = { version = "4.6.5", features = ["unstable-dynamic"] }
clap_mangen = "0.3.0"
dialoguer = { version = "0.12.0", features = ["fuzzy-select"] }
env_logger = "0.11.6"
env_logger = "0.11.10"
git-workon-lib = { version = "0.4.0", path = "./git-workon-lib" }
git-workon-fixture = { path = "./git-workon-fixture" }
git2_credentials = { version = "0.15.0", features = ["ui4dialoguer"] }
git2 = { version = "0.20.4", default-features = false, features = [
auth-git2 = "0.6"
git2 = { version = "0.21.0", default-features = false, features = [
"ssh",
"https",
] }
Expand All @@ -42,7 +42,7 @@ miette = { version = "7.6.0", features = ["fancy"] }
owo-colors = "4"
predicates = { version = "3.1.4" }
supports-color = "3"
expectrl = "0.8"
expectrl = "0.9"
pathdiff = "0.2.3"
serde_json = "1.0"
thiserror = "2.0.18"
Expand Down
1 change: 0 additions & 1 deletion git-workon-fixture/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,6 @@ assert_fs.workspace = true
env_logger.workspace = true
git-workon-lib.workspace = true
git2.workspace = true
git2_credentials.workspace = true
log.workspace = true
predicates.workspace = true
serde_json.workspace = true
2 changes: 1 addition & 1 deletion git-workon-fixture/src/predicates/has_config_multivar.rs
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ impl Predicate<Repository> for HasConfigMultivarPredicate {
if let Ok(mut entries) = config.multivar(&self.key, None) {
while let Some(entry) = entries.next() {
if let Ok(e) = entry {
if let Some(v) = e.value() {
if let Ok(v) = e.value() {
actual_values.push(v.to_string());
}
}
Expand Down
2 changes: 1 addition & 1 deletion git-workon-fixture/src/predicates/has_remote_url.rs
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ impl Predicate<Repository> for HasRemoteUrlPredicate {
match repo.find_remote(&self.remote_name) {
Ok(remote) => match &self.url {
Some(expected_url) => remote.url().map(|u| u == expected_url).unwrap_or(false),
None => remote.url().is_some(),
None => remote.url().is_ok(),
},
Err(_) => false,
}
Expand Down
24 changes: 12 additions & 12 deletions git-workon-fixture/tests/fixture_builder.rs
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ mod fixture_builder {
// Check that the branch has the initial commit
assert_eq!(
commit.message(),
Some("Initial commit"),
Ok("Initial commit"),
"Initial commit message should match"
);

Expand Down Expand Up @@ -78,7 +78,7 @@ mod fixture_builder {
// Check that the branch has the initial commit
assert_eq!(
commit.message(),
Some("Initial commit"),
Ok("Initial commit"),
"Initial commit message should match"
);

Expand Down Expand Up @@ -133,7 +133,7 @@ mod fixture_builder {
// Check that the branch has the initial commit
assert_eq!(
commit.message(),
Some("Initial commit"),
Ok("Initial commit"),
"Initial commit message should match"
);

Expand Down Expand Up @@ -191,7 +191,7 @@ mod fixture_builder {
// Check that the branch has the initial commit
assert_eq!(
commit.message(),
Some("Initial commit"),
Ok("Initial commit"),
"Initial commit message should match"
);

Expand Down Expand Up @@ -249,7 +249,7 @@ mod fixture_builder {
// Check that the branch has the initial commit
assert_eq!(
commit.message(),
Some("Initial commit"),
Ok("Initial commit"),
"Initial commit message should match"
);

Expand Down Expand Up @@ -316,7 +316,7 @@ mod fixture_builder {
// Check that the branch has the initial commit
assert_eq!(
commit.message(),
Some("Initial commit"),
Ok("Initial commit"),
"Initial commit message should match"
);

Expand Down Expand Up @@ -370,7 +370,7 @@ mod fixture_builder {
// Check that the branch has the initial commit
assert_eq!(
commit.message(),
Some("Initial commit"),
Ok("Initial commit"),
"Initial commit message should match"
);

Expand Down Expand Up @@ -431,7 +431,7 @@ mod fixture_builder {
// Check that the branch has the initial commit
assert_eq!(
commit.message(),
Some("Initial commit"),
Ok("Initial commit"),
"Initial commit message should match"
);

Expand Down Expand Up @@ -481,7 +481,7 @@ mod fixture_builder {
// Check that the branch has the initial commit
assert_eq!(
commit.message(),
Some("Initial commit"),
Ok("Initial commit"),
"Initial commit message should match"
);

Expand Down Expand Up @@ -534,7 +534,7 @@ mod fixture_builder {
// Check that the branch has the initial commit
assert_eq!(
commit.message(),
Some("Initial commit"),
Ok("Initial commit"),
"Initial commit message should match"
);

Expand Down Expand Up @@ -666,7 +666,7 @@ mod fixture_builder {

// Verify commit was created
let commit = repo.find_commit(commit_oid)?;
assert_eq!(commit.message(), Some("Add two files"));
assert_eq!(commit.message(), Ok("Add two files"));
assert_eq!(commit.parent_count(), 1);

// Verify files exist in the commit tree
Expand Down Expand Up @@ -722,7 +722,7 @@ mod fixture_builder {
assert_eq!(fixture.cwd()?.file_name(), Some(OsStr::new("docs")));

// Verify we can use the fixture to access the docs worktree
assert_eq!(fixture.head()?.name(), Some("refs/heads/docs"));
assert_eq!(fixture.head()?.name(), Ok("refs/heads/docs"));

Ok(())
}
Expand Down
2 changes: 1 addition & 1 deletion git-workon-lib/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ vendored = ["git2/vendored-libgit2", "git2/vendored-openssl"]
dialoguer.workspace = true
env_logger.workspace = true
git2.workspace = true
git2_credentials.workspace = true
auth-git2.workspace = true
glob.workspace = true
ignore.workspace = true
libc.workspace = true
Expand Down
3 changes: 2 additions & 1 deletion git-workon-lib/src/clone.rs
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,8 @@ pub fn clone(path: PathBuf, url: &str, options: CloneOptions) -> Result<Reposito

debug!("final path {}", path.display());

let mut callbacks = get_remote_callbacks_default(Some(url))?;
let auth = get_remote_callbacks_default(Some(url))?;
let mut callbacks = auth.callbacks();
callbacks.transfer_progress(move |progress| {
on_transfer_progress(
progress.received_objects(),
Expand Down
2 changes: 1 addition & 1 deletion git-workon-lib/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -332,7 +332,7 @@ impl<'repo> WorkonConfig<'repo> {
if let Ok(mut entries) = config.multivar(key, None) {
while let Some(entry) = entries.next() {
let entry = entry?;
if let Some(value) = entry.value() {
if let Ok(value) = entry.value() {
values.push(value.to_string());
}
}
Expand Down
12 changes: 7 additions & 5 deletions git-workon-lib/src/default_branch.rs
Original file line number Diff line number Diff line change
Expand Up @@ -47,12 +47,12 @@ impl<'repo, 'cb> DefaultBranch<'repo, 'cb> {
}

match cxn.default_branch()?.as_str() {
Some(default_branch) => Ok(default_branch
Ok(default_branch) => Ok(default_branch
.strip_prefix("refs/heads/")
.unwrap_or(default_branch)
.to_string()),
None => Err(DefaultBranchError::NoRemoteDefault {
remote: cxn.remote().name().map(|s| s.to_string()),
Err(_) => Err(DefaultBranchError::NoRemoteDefault {
remote: cxn.remote().name().ok().flatten().map(|s| s.to_string()),
}
.into()),
}
Expand All @@ -71,11 +71,13 @@ impl<'repo, 'cb> DefaultBranch<'repo, 'cb> {
/// Queries the remote for its default branch if one is provided, otherwise
/// falls back to `init.defaultbranch` config (defaulting to `"main"`).
pub fn get_default_branch_name(repo: &Repository, remote: Option<Remote>) -> Result<String> {
let auth;
let mut default_branch = DefaultBranch::new(repo);
if let Some(remote) = remote {
let url = remote.url().map(str::to_string);
let url = remote.url().ok().map(str::to_string);
default_branch.remote(remote);
default_branch.remote_callbacks(get_remote_callbacks(repo, url.as_deref())?);
auth = get_remote_callbacks(repo, url.as_deref())?;
default_branch.remote_callbacks(auth.callbacks());
}
default_branch.get_name().or_else(|_| {
debug!("Failed to read default branch from remote, trying git config");
Expand Down
56 changes: 36 additions & 20 deletions git-workon-lib/src/get_remote_callbacks.rs
Original file line number Diff line number Diff line change
@@ -1,36 +1,52 @@
use auth_git2::GitAuthenticator;
use git2::{Config, ConfigLevel, RemoteCallbacks, Repository};
use git2_credentials::CredentialHandler;

use crate::error::Result;
use crate::ssh_config::apply_identity_agent;

/// Build [`git2::RemoteCallbacks`] using the given repo's config to drive
/// credential resolution. Mirrors `git fetch`/`git push` precedence:
/// Holds the credential authenticator and git config needed to build
/// [`git2::RemoteCallbacks`].
///
/// auth-git2's credential closure borrows both the authenticator and the config,
/// so this struct keeps them alive while the callbacks are in use. Call
/// [`RemoteAuth::callbacks`] to obtain borrowing callbacks tied to this holder's
/// lifetime.
pub struct RemoteAuth {
authenticator: GitAuthenticator,
config: Config,
}

impl RemoteAuth {
/// Build credential callbacks that borrow this holder.
pub fn callbacks(&self) -> RemoteCallbacks<'_> {
let mut callbacks = RemoteCallbacks::new();
callbacks.credentials(self.authenticator.credentials(&self.config));
callbacks
}
}

/// Build a [`RemoteAuth`] using the given repo's config to drive credential
/// resolution. Mirrors `git fetch`/`git push` precedence:
/// local `.git/config` > worktree config > global > XDG > system.
pub fn get_remote_callbacks<'a>(
repo: &Repository,
url: Option<&str>,
) -> Result<RemoteCallbacks<'a>> {
build_callbacks(repo.config()?, url)
pub fn get_remote_callbacks(repo: &Repository, url: Option<&str>) -> Result<RemoteAuth> {
build_auth(repo.config()?, url)
}

/// Build [`git2::RemoteCallbacks`] for operations that run before a repo
/// exists (e.g. clone). Mirrors `git clone` precedence (global + XDG + system),
/// tolerating a missing `~/.gitconfig`.
pub fn get_remote_callbacks_default<'a>(url: Option<&str>) -> Result<RemoteCallbacks<'a>> {
build_callbacks(open_default_config_lenient()?, url)
/// Build a [`RemoteAuth`] for operations that run before a repo exists (e.g.
/// clone). Mirrors `git clone` precedence (global + XDG + system), tolerating
/// a missing `~/.gitconfig`.
pub fn get_remote_callbacks_default(url: Option<&str>) -> Result<RemoteAuth> {
build_auth(open_default_config_lenient()?, url)
}

fn build_callbacks<'a>(config: Config, url: Option<&str>) -> Result<RemoteCallbacks<'a>> {
fn build_auth(config: Config, url: Option<&str>) -> Result<RemoteAuth> {
if let Some(url) = url {
apply_identity_agent(url);
}
let mut callbacks = RemoteCallbacks::new();
let mut credential_handler = CredentialHandler::new(config);
callbacks.credentials(move |url, username, allowed| {
credential_handler.try_next_credential(url, username, allowed)
});
Ok(callbacks)
Ok(RemoteAuth {
authenticator: GitAuthenticator::default(),
config,
})
}

/// Like `Config::open_default()`, but tolerates a missing `~/.gitconfig`.
Expand Down
9 changes: 5 additions & 4 deletions git-workon-lib/src/pr.rs
Original file line number Diff line number Diff line change
Expand Up @@ -393,14 +393,14 @@ pub fn detect_pr_remote(repo: &Repository) -> Result<String> {

// Priority: upstream > origin
for name in &["upstream", "origin"] {
if remotes.iter().flatten().any(|r| r == *name) {
if remotes.iter().flatten().flatten().any(|r| r == *name) {
debug!("Using remote: {}", name);
return Ok(name.to_string());
}
}

// Fall back to first remote
if let Some(first_remote) = remotes.get(0) {
if let Ok(Some(first_remote)) = remotes.get(0) {
Ok(first_remote.to_string())
} else {
Err(PrError::NoRemoteConfigured.into())
Expand Down Expand Up @@ -472,9 +472,10 @@ pub fn fetch_branch(repo: &Repository, remote_name: &str, branch: &str) -> Resul
let remote_url = repo
.find_remote(remote_name)
.ok()
.and_then(|r| r.url().map(str::to_string));
.and_then(|r| r.url().ok().map(str::to_string));
let auth = get_remote_callbacks(repo, remote_url.as_deref())?;
let mut fetch_options = FetchOptions::new();
fetch_options.remote_callbacks(get_remote_callbacks(repo, remote_url.as_deref())?);
fetch_options.remote_callbacks(auth.callbacks());

repo.find_remote(remote_name)?
.fetch(
Expand Down
2 changes: 1 addition & 1 deletion git-workon-lib/src/ssh_config.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
//! Minimal `~/.ssh/config` parser for extracting `IdentityAgent`.
//!
//! libgit2 (and by extension `git2_credentials`) reads the SSH agent socket
//! libgit2 (and by extension `auth-git2`) reads the SSH agent socket
//! from `SSH_AUTH_SOCK` only — it does not honour the `IdentityAgent`
//! directive in `~/.ssh/config`. This module bridges that gap by parsing the
//! config file and applying `IdentityAgent` to the process environment before
Expand Down
2 changes: 1 addition & 1 deletion git-workon-lib/src/stack/graphite.rs
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@ fn build_parent_map(repo: &Repository) -> Result<HashMap<String, String>, StackE
let reference = reference.map_err(|e| StackError::GtParseFailed {
message: format!("failed to read branch-metadata ref: {e}"),
})?;
let Some(refname) = reference.name() else {
let Ok(refname) = reference.name() else {
continue;
};
let Some(branch) = refname.strip_prefix("refs/branch-metadata/") else {
Expand Down
Loading