forked from mirrors/jj
431 lines
14 KiB
Rust
431 lines
14 KiB
Rust
// 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::error;
|
|
use std::io::Read;
|
|
use std::io::Write;
|
|
use std::iter;
|
|
use std::path::Path;
|
|
use std::path::PathBuf;
|
|
use std::process::Stdio;
|
|
use std::time::Instant;
|
|
|
|
use itertools::Itertools;
|
|
use jj_lib::git;
|
|
use jj_lib::git::FailedRefExport;
|
|
use jj_lib::git::FailedRefExportReason;
|
|
use jj_lib::git::GitImportStats;
|
|
use jj_lib::git::RefName;
|
|
use jj_lib::git_backend::GitBackend;
|
|
use jj_lib::op_store::RefTarget;
|
|
use jj_lib::op_store::RemoteRef;
|
|
use jj_lib::repo::ReadonlyRepo;
|
|
use jj_lib::repo::Repo;
|
|
use jj_lib::store::Store;
|
|
use jj_lib::workspace::Workspace;
|
|
use unicode_width::UnicodeWidthStr;
|
|
|
|
use crate::command_error::user_error;
|
|
use crate::command_error::CommandError;
|
|
use crate::formatter::Formatter;
|
|
use crate::progress::Progress;
|
|
use crate::ui::Ui;
|
|
|
|
pub fn get_git_repo(store: &Store) -> Result<git2::Repository, CommandError> {
|
|
match store.backend_impl().downcast_ref::<GitBackend>() {
|
|
None => Err(user_error("The repo is not backed by a git repo")),
|
|
Some(git_backend) => Ok(git_backend.open_git_repo()?),
|
|
}
|
|
}
|
|
|
|
pub fn is_colocated_git_workspace(workspace: &Workspace, repo: &ReadonlyRepo) -> bool {
|
|
let Some(git_backend) = repo.store().backend_impl().downcast_ref::<GitBackend>() else {
|
|
return false;
|
|
};
|
|
let Some(git_workdir) = git_backend.git_workdir() else {
|
|
return false; // Bare repository
|
|
};
|
|
if git_workdir == workspace.workspace_root() {
|
|
return true;
|
|
}
|
|
// Colocated workspace should have ".git" directory, file, or symlink. Compare
|
|
// its parent as the git_workdir might be resolved from the real ".git" path.
|
|
let Ok(dot_git_path) = workspace.workspace_root().join(".git").canonicalize() else {
|
|
return false;
|
|
};
|
|
git_workdir.canonicalize().ok().as_deref() == dot_git_path.parent()
|
|
}
|
|
|
|
fn terminal_get_username(ui: &Ui, url: &str) -> Option<String> {
|
|
ui.prompt(&format!("Username for {url}")).ok()
|
|
}
|
|
|
|
fn terminal_get_pw(ui: &Ui, url: &str) -> Option<String> {
|
|
ui.prompt_password(&format!("Passphrase for {url}: ")).ok()
|
|
}
|
|
|
|
fn pinentry_get_pw(url: &str) -> Option<String> {
|
|
// https://www.gnupg.org/documentation/manuals/assuan/Server-responses.html#Server-responses
|
|
fn decode_assuan_data(encoded: &str) -> Option<String> {
|
|
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()?;
|
|
let mut interact = || -> std::io::Result<_> {
|
|
#[rustfmt::skip]
|
|
let req = format!(
|
|
"SETTITLE jj passphrase\n\
|
|
SETDESC Enter passphrase for {url}\n\
|
|
SETPROMPT Passphrase:\n\
|
|
GETPIN\n"
|
|
);
|
|
pinentry.stdin.take().unwrap().write_all(req.as_bytes())?;
|
|
let mut out = String::new();
|
|
pinentry.stdout.take().unwrap().read_to_string(&mut out)?;
|
|
Ok(out)
|
|
};
|
|
let maybe_out = interact();
|
|
_ = pinentry.wait();
|
|
for line in maybe_out.ok()?.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<PathBuf> {
|
|
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
|
|
}
|
|
|
|
// Based on Git's implementation: https://github.com/git/git/blob/43072b4ca132437f21975ac6acc6b72dc22fd398/sideband.c#L178
|
|
pub struct GitSidebandProgressMessageWriter {
|
|
display_prefix: &'static [u8],
|
|
suffix: &'static [u8],
|
|
scratch: Vec<u8>,
|
|
}
|
|
|
|
impl GitSidebandProgressMessageWriter {
|
|
pub fn new(ui: &Ui) -> Self {
|
|
let is_terminal = ui.use_progress_indicator();
|
|
|
|
GitSidebandProgressMessageWriter {
|
|
display_prefix: "remote: ".as_bytes(),
|
|
suffix: if is_terminal { "\x1B[K" } else { " " }.as_bytes(),
|
|
scratch: Vec::new(),
|
|
}
|
|
}
|
|
|
|
pub fn write(&mut self, ui: &Ui, progress_message: &[u8]) -> std::io::Result<()> {
|
|
let mut index = 0;
|
|
// Append a suffix to each nonempty line to clear the end of the screen line.
|
|
loop {
|
|
let Some(i) = progress_message[index..]
|
|
.iter()
|
|
.position(|&c| c == b'\r' || c == b'\n')
|
|
.map(|i| index + i)
|
|
else {
|
|
break;
|
|
};
|
|
let line_length = i - index;
|
|
|
|
// For messages sent across the packet boundary, there would be a nonempty
|
|
// "scratch" buffer from last call of this function, and there may be a leading
|
|
// CR/LF in this message. For this case we should add a clear-to-eol suffix to
|
|
// clean leftover letters we previously have written on the same line.
|
|
if !self.scratch.is_empty() && line_length == 0 {
|
|
self.scratch.extend_from_slice(self.suffix);
|
|
}
|
|
|
|
if self.scratch.is_empty() {
|
|
self.scratch.extend_from_slice(self.display_prefix);
|
|
}
|
|
|
|
// Do not add the clear-to-eol suffix to empty lines:
|
|
// For progress reporting we may receive a bunch of percentage updates
|
|
// followed by '\r' to remain on the same line, and at the end receive a single
|
|
// '\n' to move to the next line. We should preserve the final
|
|
// status report line by not appending clear-to-eol suffix to this single line
|
|
// break.
|
|
if line_length > 0 {
|
|
self.scratch.extend_from_slice(&progress_message[index..i]);
|
|
self.scratch.extend_from_slice(self.suffix);
|
|
}
|
|
self.scratch.extend_from_slice(&progress_message[i..i + 1]);
|
|
|
|
ui.status().write_all(&self.scratch)?;
|
|
self.scratch.clear();
|
|
|
|
index = i + 1;
|
|
}
|
|
|
|
// Add leftover message to "scratch" buffer to be printed in next call.
|
|
if index < progress_message.len() && progress_message[index] != 0 {
|
|
if self.scratch.is_empty() {
|
|
self.scratch.extend_from_slice(self.display_prefix);
|
|
}
|
|
self.scratch.extend_from_slice(&progress_message[index..]);
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
pub fn flush(&mut self, ui: &Ui) -> std::io::Result<()> {
|
|
if !self.scratch.is_empty() {
|
|
self.scratch.push(b'\n');
|
|
ui.status().write_all(&self.scratch)?;
|
|
self.scratch.clear();
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
type SidebandProgressCallback<'a> = &'a mut dyn FnMut(&[u8]);
|
|
|
|
pub fn with_remote_git_callbacks<T>(
|
|
ui: &Ui,
|
|
sideband_progress_callback: Option<SidebandProgressCallback<'_>>,
|
|
f: impl FnOnce(git::RemoteCallbacks<'_>) -> T,
|
|
) -> T {
|
|
let mut callbacks = git::RemoteCallbacks::default();
|
|
let mut progress_callback = None;
|
|
if let Some(mut output) = ui.progress_output() {
|
|
let mut progress = Progress::new(Instant::now());
|
|
progress_callback = Some(move |x: &git::Progress| {
|
|
_ = progress.update(Instant::now(), x, &mut output);
|
|
});
|
|
}
|
|
callbacks.progress = progress_callback
|
|
.as_mut()
|
|
.map(|x| x as &mut dyn FnMut(&git::Progress));
|
|
callbacks.sideband_progress = sideband_progress_callback.map(|x| x as &mut dyn FnMut(&[u8]));
|
|
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, url));
|
|
callbacks.get_password = Some(&mut get_pw);
|
|
let mut get_user_pw =
|
|
|url: &str| 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,
|
|
repo: &dyn Repo,
|
|
stats: &GitImportStats,
|
|
show_ref_stats: bool,
|
|
) -> Result<(), CommandError> {
|
|
let Some(mut formatter) = ui.status_formatter() else {
|
|
return Ok(());
|
|
};
|
|
if show_ref_stats {
|
|
let refs_stats = stats
|
|
.changed_remote_refs
|
|
.iter()
|
|
.map(|(ref_name, (remote_ref, ref_target))| {
|
|
RefStatus::new(ref_name, remote_ref, ref_target, repo)
|
|
})
|
|
.collect_vec();
|
|
|
|
let has_both_ref_kinds = refs_stats
|
|
.iter()
|
|
.any(|x| matches!(x.ref_kind, RefKind::Branch))
|
|
&& refs_stats
|
|
.iter()
|
|
.any(|x| matches!(x.ref_kind, RefKind::Tag));
|
|
|
|
let max_width = refs_stats.iter().map(|x| x.ref_name.width()).max();
|
|
if let Some(max_width) = max_width {
|
|
for status in refs_stats {
|
|
status.output(max_width, has_both_ref_kinds, &mut *formatter)?;
|
|
}
|
|
}
|
|
}
|
|
|
|
if !stats.abandoned_commits.is_empty() {
|
|
writeln!(
|
|
formatter,
|
|
"Abandoned {} commits that are no longer reachable.",
|
|
stats.abandoned_commits.len()
|
|
)?;
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
struct RefStatus {
|
|
ref_kind: RefKind,
|
|
ref_name: String,
|
|
tracking_status: TrackingStatus,
|
|
import_status: ImportStatus,
|
|
}
|
|
|
|
impl RefStatus {
|
|
fn new(
|
|
ref_name: &RefName,
|
|
remote_ref: &RemoteRef,
|
|
ref_target: &RefTarget,
|
|
repo: &dyn Repo,
|
|
) -> Self {
|
|
let (ref_name, ref_kind, tracking_status) = match ref_name {
|
|
RefName::RemoteBranch { branch, remote } => (
|
|
format!("{branch}@{remote}"),
|
|
RefKind::Branch,
|
|
if repo.view().get_remote_branch(branch, remote).is_tracking() {
|
|
TrackingStatus::Tracked
|
|
} else {
|
|
TrackingStatus::Untracked
|
|
},
|
|
),
|
|
RefName::Tag(tag) => (tag.clone(), RefKind::Tag, TrackingStatus::NotApplicable),
|
|
RefName::LocalBranch(branch) => {
|
|
(branch.clone(), RefKind::Branch, TrackingStatus::Tracked)
|
|
}
|
|
};
|
|
|
|
let import_status = match (remote_ref.target.is_absent(), ref_target.is_absent()) {
|
|
(true, false) => ImportStatus::New,
|
|
(false, true) => ImportStatus::Deleted,
|
|
_ => ImportStatus::Updated,
|
|
};
|
|
|
|
Self {
|
|
ref_name,
|
|
tracking_status,
|
|
import_status,
|
|
ref_kind,
|
|
}
|
|
}
|
|
|
|
fn output(
|
|
&self,
|
|
max_ref_name_width: usize,
|
|
has_both_ref_kinds: bool,
|
|
out: &mut dyn Formatter,
|
|
) -> std::io::Result<()> {
|
|
let tracking_status = match self.tracking_status {
|
|
TrackingStatus::Tracked => "tracked",
|
|
TrackingStatus::Untracked => "untracked",
|
|
TrackingStatus::NotApplicable => "",
|
|
};
|
|
|
|
let import_status = match self.import_status {
|
|
ImportStatus::New => "new",
|
|
ImportStatus::Deleted => "deleted",
|
|
ImportStatus::Updated => "updated",
|
|
};
|
|
|
|
let ref_name_display_width = self.ref_name.width();
|
|
let pad_width = max_ref_name_width.saturating_sub(ref_name_display_width);
|
|
let padded_ref_name = format!("{}{:>pad_width$}", self.ref_name, "", pad_width = pad_width);
|
|
|
|
let ref_kind = match self.ref_kind {
|
|
RefKind::Branch => "branch: ",
|
|
RefKind::Tag if !has_both_ref_kinds => "tag: ",
|
|
RefKind::Tag => "tag: ",
|
|
};
|
|
|
|
write!(out, "{ref_kind}")?;
|
|
write!(out.labeled("branch"), "{padded_ref_name}")?;
|
|
writeln!(out, " [{import_status}] {tracking_status}")
|
|
}
|
|
}
|
|
|
|
enum RefKind {
|
|
Branch,
|
|
Tag,
|
|
}
|
|
|
|
enum TrackingStatus {
|
|
Tracked,
|
|
Untracked,
|
|
NotApplicable, // for tags
|
|
}
|
|
|
|
enum ImportStatus {
|
|
New,
|
|
Deleted,
|
|
Updated,
|
|
}
|
|
|
|
pub fn print_failed_git_export(
|
|
ui: &Ui,
|
|
failed_branches: &[FailedRefExport],
|
|
) -> Result<(), std::io::Error> {
|
|
if !failed_branches.is_empty() {
|
|
writeln!(ui.warning_default(), "Failed to export some branches:")?;
|
|
let mut formatter = ui.stderr_formatter();
|
|
for FailedRefExport { name, reason } in failed_branches {
|
|
write!(formatter, " ")?;
|
|
write!(formatter.labeled("branch"), "{name}")?;
|
|
for err in iter::successors(Some(reason as &dyn error::Error), |err| err.source()) {
|
|
write!(formatter, ": {err}")?;
|
|
}
|
|
writeln!(formatter)?;
|
|
}
|
|
drop(formatter);
|
|
if failed_branches
|
|
.iter()
|
|
.any(|failed| matches!(failed.reason, FailedRefExportReason::FailedToSet(_)))
|
|
{
|
|
writeln!(
|
|
ui.hint_default(),
|
|
r#"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(())
|
|
}
|