From bd110307ff1e17264ddec32a8083c31d38c588d9 Mon Sep 17 00:00:00 2001 From: Austin Seipp Date: Wed, 17 Jan 2024 19:57:43 -0600 Subject: [PATCH] cli: move some git utilities to new `crate::git_utils` Gerrit also needs to be able to push low-level refs into upstream remotes, just like `jj git push` does. Doing so requires providing callbacks e.g. for various password entry mechanisms, which was private to the `git` command module. Pull these out to a new module `git_utils` so we can reuse it across the two call sites. This also moves a few other strictly Git-related functions into `git_utils` as well, just for the sake of consistency. Signed-off-by: Austin Seipp --- cli/src/cli_util.rs | 59 +----------- cli/src/commands/git.rs | 143 ++-------------------------- cli/src/commands/init.rs | 5 +- cli/src/git_util.rs | 201 +++++++++++++++++++++++++++++++++++++++ cli/src/lib.rs | 1 + 5 files changed, 219 insertions(+), 190 deletions(-) create mode 100644 cli/src/git_util.rs diff --git a/cli/src/cli_util.rs b/cli/src/cli_util.rs index a652dcb46..0c148d304 100644 --- a/cli/src/cli_util.rs +++ b/cli/src/cli_util.rs @@ -32,10 +32,7 @@ use indexmap::{IndexMap, IndexSet}; use itertools::Itertools; use jj_lib::backend::{BackendError, ChangeId, CommitId, MergedTreeId}; use jj_lib::commit::Commit; -use jj_lib::git::{ - FailedRefExport, FailedRefExportReason, GitConfigParseError, GitExportError, GitImportError, - GitImportStats, GitRemoteManagementError, -}; +use jj_lib::git::{GitConfigParseError, GitExportError, GitImportError, GitRemoteManagementError}; use jj_lib::git_backend::GitBackend; use jj_lib::gitignore::GitIgnoreFile; use jj_lib::hex_util::to_reverse_hex; @@ -82,6 +79,7 @@ use crate::config::{ new_config_path, AnnotatedValue, CommandNameAndArgs, ConfigSource, LayeredConfigs, }; use crate::formatter::{FormatRecorder, Formatter, PlainTextFormatter}; +use crate::git_util::{print_failed_git_export, print_git_import_stats}; use crate::merge_tools::{ConflictResolveError, DiffEditError, DiffGenerateError}; use crate::template_parser::{TemplateAliasesMap, TemplateParseError}; use crate::templater::Template; @@ -946,7 +944,9 @@ impl WorkspaceCommandHelper { // TODO: maybe use path_by_key() and interpolate(), which can process non-utf-8 // path on Unix. if let Some(value) = config.string_by_key("core.excludesFile") { - str::from_utf8(&value).ok().map(expand_git_path) + str::from_utf8(&value) + .ok() + .map(crate::git_util::expand_git_path) } else { xdg_config_home().ok().map(|x| x.join("git").join("ignore")) } @@ -1943,55 +1943,6 @@ Discard the conflicting changes with `jj restore --from {}`.", Ok(()) } -pub fn print_git_import_stats(ui: &mut Ui, stats: &GitImportStats) -> Result<(), CommandError> { - if !stats.abandoned_commits.is_empty() { - writeln!( - ui.stderr(), - "Abandoned {} commits that are no longer reachable.", - stats.abandoned_commits.len() - )?; - } - Ok(()) -} - -pub fn print_failed_git_export( - ui: &Ui, - failed_branches: &[FailedRefExport], -) -> Result<(), std::io::Error> { - if !failed_branches.is_empty() { - writeln!(ui.warning(), "Failed to export some branches:")?; - let mut formatter = ui.stderr_formatter(); - for FailedRefExport { name, reason } in failed_branches { - formatter.write_str(" ")?; - write!(formatter.labeled("branch"), "{name}")?; - writeln!(formatter, ": {reason}")?; - } - drop(formatter); - if failed_branches - .iter() - .any(|failed| matches!(failed.reason, FailedRefExportReason::FailedToSet(_))) - { - writeln!( - ui.hint(), - r#"Hint: Git doesn't allow a branch name that looks like a parent directory of -another (e.g. `foo` and `foo/bar`). Try to rename the branches that failed to -export or their "parent" branches."#, - )?; - } - } - Ok(()) -} - -/// Expands "~/" to "$HOME/" as Git seems to do for e.g. core.excludesFile. -fn expand_git_path(path_str: &str) -> PathBuf { - if let Some(remainder) = path_str.strip_prefix("~/") { - if let Ok(home_dir_str) = std::env::var("HOME") { - return PathBuf::from(home_dir_str).join(remainder); - } - } - PathBuf::from(path_str) -} - pub fn parse_string_pattern(src: &str) -> Result { if let Some((kind, pat)) = src.split_once(':') { StringPattern::from_str_kind(pat, kind) diff --git a/cli/src/commands/git.rs b/cli/src/commands/git.rs index b9c3731f7..8805f9d18 100644 --- a/cli/src/commands/git.rs +++ b/cli/src/commands/git.rs @@ -13,12 +13,9 @@ // limitations under the License. use std::collections::HashSet; -use std::io::{Read, Write}; +use std::io::Write; use std::ops::Deref; use std::path::{Path, PathBuf}; -use std::process::{Command, Stdio}; -use std::sync::Mutex; -use std::time::Instant; use std::{fmt, fs, io}; use clap::{ArgGroup, Subcommand}; @@ -27,7 +24,6 @@ use jj_lib::backend::TreeValue; use jj_lib::git::{ self, parse_gitmodules, GitBranchPushTargets, GitFetchError, GitFetchStats, GitPushError, }; -use jj_lib::git_backend::GitBackend; use jj_lib::object_id::ObjectId; use jj_lib::op_store::RefTarget; use jj_lib::refs::{ @@ -37,18 +33,19 @@ use jj_lib::repo::Repo; use jj_lib::repo_path::RepoPath; use jj_lib::revset::{self, RevsetExpression, RevsetIteratorExt as _}; use jj_lib::settings::{ConfigResultExt as _, UserSettings}; -use jj_lib::store::Store; use jj_lib::str_util::StringPattern; use jj_lib::view::View; use jj_lib::workspace::Workspace; use maplit::hashset; use crate::cli_util::{ - parse_string_pattern, print_failed_git_export, print_git_import_stats, - resolve_multiple_nonempty_revsets, short_change_hash, short_commit_hash, user_error, - user_error_with_hint, CommandError, CommandHelper, RevisionArg, WorkspaceCommandHelper, + parse_string_pattern, resolve_multiple_nonempty_revsets, short_change_hash, short_commit_hash, + user_error, user_error_with_hint, CommandError, CommandHelper, RevisionArg, + WorkspaceCommandHelper, +}; +use crate::git_util::{ + get_git_repo, print_failed_git_export, print_git_import_stats, with_remote_git_callbacks, }; -use crate::progress::Progress; use crate::ui::Ui; /// Commands for working with the underlying Git repo @@ -212,13 +209,6 @@ fn make_branch_term(branch_names: &[impl fmt::Display]) -> String { } } -fn get_git_repo(store: &Store) -> Result { - match store.backend_impl().downcast_ref::() { - None => Err(user_error("The repo is not backed by a git repo")), - Some(git_backend) => Ok(git_backend.open_git_repo()?), - } -} - fn map_git_error(err: git2::Error) -> CommandError { if err.class() == git2::ErrorClass::Ssh { let hint = @@ -338,7 +328,7 @@ fn cmd_git_fetch( }; let mut tx = workspace_command.start_transaction(); for remote in &remotes { - let stats = with_remote_callbacks(ui, |cb| { + let stats = with_remote_git_callbacks(ui, |cb| { git::fetch( tx.mut_repo(), &git_repo, @@ -556,7 +546,7 @@ fn do_git_clone( git_repo.remote(remote_name, source).unwrap(); let mut fetch_tx = workspace_command.start_transaction(); - let stats = with_remote_callbacks(ui, |cb| { + let stats = with_remote_git_callbacks(ui, |cb| { git::fetch( fetch_tx.mut_repo(), &git_repo, @@ -581,119 +571,6 @@ fn do_git_clone( Ok((workspace_command, stats)) } -fn with_remote_callbacks(ui: &mut Ui, f: impl FnOnce(git::RemoteCallbacks<'_>) -> T) -> T { - let mut ui = Mutex::new(ui); - let mut callback = None; - if let Some(mut output) = ui.get_mut().unwrap().progress_output() { - let mut progress = Progress::new(Instant::now()); - callback = Some(move |x: &git::Progress| { - _ = progress.update(Instant::now(), x, &mut output); - }); - } - let mut callbacks = git::RemoteCallbacks::default(); - callbacks.progress = callback - .as_mut() - .map(|x| x as &mut dyn FnMut(&git::Progress)); - let mut get_ssh_keys = get_ssh_keys; // Coerce to unit fn type - callbacks.get_ssh_keys = Some(&mut get_ssh_keys); - let mut get_pw = |url: &str, _username: &str| { - pinentry_get_pw(url).or_else(|| terminal_get_pw(*ui.lock().unwrap(), url)) - }; - callbacks.get_password = Some(&mut get_pw); - let mut get_user_pw = |url: &str| { - let ui = &mut *ui.lock().unwrap(); - Some((terminal_get_username(ui, url)?, terminal_get_pw(ui, url)?)) - }; - callbacks.get_username_password = Some(&mut get_user_pw); - f(callbacks) -} - -fn terminal_get_username(ui: &mut Ui, url: &str) -> Option { - ui.prompt(&format!("Username for {url}")).ok() -} - -fn terminal_get_pw(ui: &mut Ui, url: &str) -> Option { - ui.prompt_password(&format!("Passphrase for {url}: ")).ok() -} - -fn pinentry_get_pw(url: &str) -> Option { - let mut pinentry = Command::new("pinentry") - .stdin(Stdio::piped()) - .stdout(Stdio::piped()) - .spawn() - .ok()?; - #[rustfmt::skip] - pinentry - .stdin - .take() - .unwrap() - .write_all( - format!( - "SETTITLE jj passphrase\n\ - SETDESC Enter passphrase for {url}\n\ - SETPROMPT Passphrase:\n\ - GETPIN\n" - ) - .as_bytes(), - ) - .ok()?; - let mut out = String::new(); - pinentry - .stdout - .take() - .unwrap() - .read_to_string(&mut out) - .ok()?; - _ = pinentry.wait(); - for line in out.split('\n') { - if !line.starts_with("D ") { - continue; - } - let (_, encoded) = line.split_at(2); - return decode_assuan_data(encoded); - } - None -} - -// https://www.gnupg.org/documentation/manuals/assuan/Server-responses.html#Server-responses -fn decode_assuan_data(encoded: &str) -> Option { - let encoded = encoded.as_bytes(); - let mut decoded = Vec::with_capacity(encoded.len()); - let mut i = 0; - while i < encoded.len() { - if encoded[i] != b'%' { - decoded.push(encoded[i]); - i += 1; - continue; - } - i += 1; - let byte = - u8::from_str_radix(std::str::from_utf8(encoded.get(i..i + 2)?).ok()?, 16).ok()?; - decoded.push(byte); - i += 2; - } - String::from_utf8(decoded).ok() -} - -#[tracing::instrument] -fn get_ssh_keys(_username: &str) -> Vec { - let mut paths = vec![]; - if let Some(home_dir) = dirs::home_dir() { - let ssh_dir = Path::new(&home_dir).join(".ssh"); - for filename in ["id_ed25519_sk", "id_ed25519", "id_rsa"] { - let key_path = ssh_dir.join(filename); - if key_path.is_file() { - tracing::info!(path = ?key_path, "found ssh key"); - paths.push(key_path); - } - } - } - if paths.is_empty() { - tracing::info!("no ssh key found"); - } - paths -} - fn cmd_git_push( ui: &mut Ui, command: &CommandHelper, @@ -978,7 +855,7 @@ fn cmd_git_push( branch_updates, force_pushed_branches, }; - with_remote_callbacks(ui, |cb| { + with_remote_git_callbacks(ui, |cb| { git::push_branches(tx.mut_repo(), &git_repo, &remote, &targets, cb) }) .map_err(|err| match err { diff --git a/cli/src/commands/init.rs b/cli/src/commands/init.rs index 906ba8531..95a63ac80 100644 --- a/cli/src/commands/init.rs +++ b/cli/src/commands/init.rs @@ -24,9 +24,8 @@ use jj_lib::workspace::Workspace; use tracing::instrument; use super::git; -use crate::cli_util::{ - print_git_import_stats, user_error, user_error_with_hint, CommandError, CommandHelper, -}; +use crate::cli_util::{user_error, user_error_with_hint, CommandError, CommandHelper}; +use crate::git_util::print_git_import_stats; use crate::ui::Ui; /// Create a new repo in the given directory diff --git a/cli/src/git_util.rs b/cli/src/git_util.rs new file mode 100644 index 000000000..04f2d86ea --- /dev/null +++ b/cli/src/git_util.rs @@ -0,0 +1,201 @@ +// Copyright 2024 The Jujutsu Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//! Git utilities shared by various commands. + +use std::io::{Read, Write}; +use std::path::{Path, PathBuf}; +use std::process::Stdio; +use std::sync::Mutex; +use std::time::Instant; + +use jj_lib::git::{self, FailedRefExport, FailedRefExportReason, GitImportStats}; +use jj_lib::git_backend::GitBackend; +use jj_lib::store::Store; + +use crate::cli_util::{user_error, CommandError}; +use crate::progress::Progress; +use crate::ui::Ui; + +pub fn get_git_repo(store: &Store) -> Result { + match store.backend_impl().downcast_ref::() { + None => Err(user_error("The repo is not backed by a git repo")), + Some(git_backend) => Ok(git_backend.open_git_repo()?), + } +} + +fn terminal_get_username(ui: &mut Ui, url: &str) -> Option { + ui.prompt(&format!("Username for {url}")).ok() +} + +fn terminal_get_pw(ui: &mut Ui, url: &str) -> Option { + ui.prompt_password(&format!("Passphrase for {url}: ")).ok() +} + +fn pinentry_get_pw(url: &str) -> Option { + // https://www.gnupg.org/documentation/manuals/assuan/Server-responses.html#Server-responses + fn decode_assuan_data(encoded: &str) -> Option { + let encoded = encoded.as_bytes(); + let mut decoded = Vec::with_capacity(encoded.len()); + let mut i = 0; + while i < encoded.len() { + if encoded[i] != b'%' { + decoded.push(encoded[i]); + i += 1; + continue; + } + i += 1; + let byte = + u8::from_str_radix(std::str::from_utf8(encoded.get(i..i + 2)?).ok()?, 16).ok()?; + decoded.push(byte); + i += 2; + } + String::from_utf8(decoded).ok() + } + + let mut pinentry = std::process::Command::new("pinentry") + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .spawn() + .ok()?; + #[rustfmt::skip] + pinentry + .stdin + .take() + .unwrap() + .write_all( + format!( + "SETTITLE jj passphrase\n\ + SETDESC Enter passphrase for {url}\n\ + SETPROMPT Passphrase:\n\ + GETPIN\n" + ) + .as_bytes(), + ) + .ok()?; + let mut out = String::new(); + pinentry + .stdout + .take() + .unwrap() + .read_to_string(&mut out) + .ok()?; + _ = pinentry.wait(); + for line in out.split('\n') { + if !line.starts_with("D ") { + continue; + } + let (_, encoded) = line.split_at(2); + return decode_assuan_data(encoded); + } + None +} + +#[tracing::instrument] +fn get_ssh_keys(_username: &str) -> Vec { + let mut paths = vec![]; + if let Some(home_dir) = dirs::home_dir() { + let ssh_dir = Path::new(&home_dir).join(".ssh"); + for filename in ["id_ed25519_sk", "id_ed25519", "id_rsa"] { + let key_path = ssh_dir.join(filename); + if key_path.is_file() { + tracing::info!(path = ?key_path, "found ssh key"); + paths.push(key_path); + } + } + } + if paths.is_empty() { + tracing::info!("no ssh key found"); + } + paths +} + +pub fn with_remote_git_callbacks( + ui: &mut Ui, + f: impl FnOnce(git::RemoteCallbacks<'_>) -> T, +) -> T { + let mut ui = Mutex::new(ui); + let mut callback = None; + if let Some(mut output) = ui.get_mut().unwrap().progress_output() { + let mut progress = Progress::new(Instant::now()); + callback = Some(move |x: &git::Progress| { + _ = progress.update(Instant::now(), x, &mut output); + }); + } + let mut callbacks = git::RemoteCallbacks::default(); + callbacks.progress = callback + .as_mut() + .map(|x| x as &mut dyn FnMut(&git::Progress)); + let mut get_ssh_keys = get_ssh_keys; // Coerce to unit fn type + callbacks.get_ssh_keys = Some(&mut get_ssh_keys); + let mut get_pw = |url: &str, _username: &str| { + pinentry_get_pw(url).or_else(|| terminal_get_pw(*ui.lock().unwrap(), url)) + }; + callbacks.get_password = Some(&mut get_pw); + let mut get_user_pw = |url: &str| { + let ui = &mut *ui.lock().unwrap(); + Some((terminal_get_username(ui, url)?, terminal_get_pw(ui, url)?)) + }; + callbacks.get_username_password = Some(&mut get_user_pw); + f(callbacks) +} + +pub fn print_git_import_stats(ui: &mut Ui, stats: &GitImportStats) -> Result<(), CommandError> { + if !stats.abandoned_commits.is_empty() { + writeln!( + ui.stderr(), + "Abandoned {} commits that are no longer reachable.", + stats.abandoned_commits.len() + )?; + } + Ok(()) +} + +pub fn print_failed_git_export( + ui: &Ui, + failed_branches: &[FailedRefExport], +) -> Result<(), std::io::Error> { + if !failed_branches.is_empty() { + writeln!(ui.warning(), "Failed to export some branches:")?; + let mut formatter = ui.stderr_formatter(); + for FailedRefExport { name, reason } in failed_branches { + formatter.write_str(" ")?; + write!(formatter.labeled("branch"), "{name}")?; + writeln!(formatter, ": {reason}")?; + } + drop(formatter); + if failed_branches + .iter() + .any(|failed| matches!(failed.reason, FailedRefExportReason::FailedToSet(_))) + { + writeln!( + ui.hint(), + r#"Hint: Git doesn't allow a branch name that looks like a parent directory of +another (e.g. `foo` and `foo/bar`). Try to rename the branches that failed to +export or their "parent" branches."#, + )?; + } + } + Ok(()) +} + +/// Expands "~/" to "$HOME/" as Git seems to do for e.g. core.excludesFile. +pub fn expand_git_path(path_str: &str) -> PathBuf { + if let Some(remainder) = path_str.strip_prefix("~/") { + if let Ok(home_dir_str) = std::env::var("HOME") { + return PathBuf::from(home_dir_str).join(remainder); + } + } + PathBuf::from(path_str) +} diff --git a/cli/src/lib.rs b/cli/src/lib.rs index a274abef2..32bb24a92 100644 --- a/cli/src/lib.rs +++ b/cli/src/lib.rs @@ -22,6 +22,7 @@ pub mod config; pub mod description_util; pub mod diff_util; pub mod formatter; +pub mod git_util; pub mod graphlog; pub mod merge_tools; pub mod operation_templater;