just playing with tangled
at diffedit3 433 lines 15 kB view raw
1// Copyright 2024 The Jujutsu Authors 2// 3// Licensed under the Apache License, Version 2.0 (the "License"); 4// you may not use this file except in compliance with the License. 5// You may obtain a copy of the License at 6// 7// https://www.apache.org/licenses/LICENSE-2.0 8// 9// Unless required by applicable law or agreed to in writing, software 10// distributed under the License is distributed on an "AS IS" BASIS, 11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12// See the License for the specific language governing permissions and 13// limitations under the License. 14 15//! Git utilities shared by various commands. 16 17use std::io::{Read, Write}; 18use std::path::{Path, PathBuf}; 19use std::process::Stdio; 20use std::time::Instant; 21use std::{error, iter}; 22 23use itertools::Itertools; 24use jj_lib::git::{self, FailedRefExport, FailedRefExportReason, GitImportStats, RefName}; 25use jj_lib::git_backend::GitBackend; 26use jj_lib::op_store::{RefTarget, RemoteRef}; 27use jj_lib::repo::{ReadonlyRepo, Repo}; 28use jj_lib::store::Store; 29use jj_lib::workspace::Workspace; 30use unicode_width::UnicodeWidthStr; 31 32use crate::command_error::{user_error, CommandError}; 33use crate::formatter::Formatter; 34use crate::progress::Progress; 35use crate::ui::Ui; 36 37pub fn get_git_repo(store: &Store) -> Result<git2::Repository, CommandError> { 38 match store.backend_impl().downcast_ref::<GitBackend>() { 39 None => Err(user_error("The repo is not backed by a git repo")), 40 Some(git_backend) => Ok(git_backend.open_git_repo()?), 41 } 42} 43 44pub fn is_colocated_git_workspace(workspace: &Workspace, repo: &ReadonlyRepo) -> bool { 45 let Some(git_backend) = repo.store().backend_impl().downcast_ref::<GitBackend>() else { 46 return false; 47 }; 48 let Some(git_workdir) = git_backend.git_workdir() else { 49 return false; // Bare repository 50 }; 51 if git_workdir == workspace.workspace_root() { 52 return true; 53 } 54 // Colocated workspace should have ".git" directory, file, or symlink. Compare 55 // its parent as the git_workdir might be resolved from the real ".git" path. 56 let Ok(dot_git_path) = workspace.workspace_root().join(".git").canonicalize() else { 57 return false; 58 }; 59 git_workdir.canonicalize().ok().as_deref() == dot_git_path.parent() 60} 61 62fn terminal_get_username(ui: &Ui, url: &str) -> Option<String> { 63 ui.prompt(&format!("Username for {url}")).ok() 64} 65 66fn terminal_get_pw(ui: &Ui, url: &str) -> Option<String> { 67 ui.prompt_password(&format!("Passphrase for {url}: ")).ok() 68} 69 70fn pinentry_get_pw(url: &str) -> Option<String> { 71 // https://www.gnupg.org/documentation/manuals/assuan/Server-responses.html#Server-responses 72 fn decode_assuan_data(encoded: &str) -> Option<String> { 73 let encoded = encoded.as_bytes(); 74 let mut decoded = Vec::with_capacity(encoded.len()); 75 let mut i = 0; 76 while i < encoded.len() { 77 if encoded[i] != b'%' { 78 decoded.push(encoded[i]); 79 i += 1; 80 continue; 81 } 82 i += 1; 83 let byte = 84 u8::from_str_radix(std::str::from_utf8(encoded.get(i..i + 2)?).ok()?, 16).ok()?; 85 decoded.push(byte); 86 i += 2; 87 } 88 String::from_utf8(decoded).ok() 89 } 90 91 let mut pinentry = std::process::Command::new("pinentry") 92 .stdin(Stdio::piped()) 93 .stdout(Stdio::piped()) 94 .spawn() 95 .ok()?; 96 let mut interact = || -> std::io::Result<_> { 97 #[rustfmt::skip] 98 let req = format!( 99 "SETTITLE jj passphrase\n\ 100 SETDESC Enter passphrase for {url}\n\ 101 SETPROMPT Passphrase:\n\ 102 GETPIN\n" 103 ); 104 pinentry.stdin.take().unwrap().write_all(req.as_bytes())?; 105 let mut out = String::new(); 106 pinentry.stdout.take().unwrap().read_to_string(&mut out)?; 107 Ok(out) 108 }; 109 let maybe_out = interact(); 110 _ = pinentry.wait(); 111 for line in maybe_out.ok()?.split('\n') { 112 if !line.starts_with("D ") { 113 continue; 114 } 115 let (_, encoded) = line.split_at(2); 116 return decode_assuan_data(encoded); 117 } 118 None 119} 120 121#[tracing::instrument] 122fn get_ssh_keys(_username: &str) -> Vec<PathBuf> { 123 let mut paths = vec![]; 124 if let Some(home_dir) = dirs::home_dir() { 125 let ssh_dir = Path::new(&home_dir).join(".ssh"); 126 for filename in ["id_ed25519_sk", "id_ed25519", "id_rsa"] { 127 let key_path = ssh_dir.join(filename); 128 if key_path.is_file() { 129 tracing::info!(path = ?key_path, "found ssh key"); 130 paths.push(key_path); 131 } 132 } 133 } 134 if paths.is_empty() { 135 tracing::info!("no ssh key found"); 136 } 137 paths 138} 139 140// Based on Git's implementation: https://github.com/git/git/blob/43072b4ca132437f21975ac6acc6b72dc22fd398/sideband.c#L178 141pub struct GitSidebandProgressMessageWriter { 142 display_prefix: &'static [u8], 143 suffix: &'static [u8], 144 scratch: Vec<u8>, 145} 146 147impl GitSidebandProgressMessageWriter { 148 pub fn new(ui: &Ui) -> Self { 149 let is_terminal = ui.use_progress_indicator(); 150 151 GitSidebandProgressMessageWriter { 152 display_prefix: "remote: ".as_bytes(), 153 suffix: if is_terminal { "\x1B[K" } else { " " }.as_bytes(), 154 scratch: Vec::new(), 155 } 156 } 157 158 pub fn write(&mut self, ui: &Ui, progress_message: &[u8]) -> std::io::Result<()> { 159 let mut index = 0; 160 // Append a suffix to each nonempty line to clear the end of the screen line. 161 loop { 162 let Some(i) = progress_message[index..] 163 .iter() 164 .position(|&c| c == b'\r' || c == b'\n') 165 .map(|i| index + i) 166 else { 167 break; 168 }; 169 let line_length = i - index; 170 171 // For messages sent across the packet boundary, there would be a nonempty 172 // "scratch" buffer from last call of this function, and there may be a leading 173 // CR/LF in this message. For this case we should add a clear-to-eol suffix to 174 // clean leftover letters we previously have written on the same line. 175 if !self.scratch.is_empty() && line_length == 0 { 176 self.scratch.extend_from_slice(self.suffix); 177 } 178 179 if self.scratch.is_empty() { 180 self.scratch.extend_from_slice(self.display_prefix); 181 } 182 183 // Do not add the clear-to-eol suffix to empty lines: 184 // For progress reporting we may receive a bunch of percentage updates 185 // followed by '\r' to remain on the same line, and at the end receive a single 186 // '\n' to move to the next line. We should preserve the final 187 // status report line by not appending clear-to-eol suffix to this single line 188 // break. 189 if line_length > 0 { 190 self.scratch.extend_from_slice(&progress_message[index..i]); 191 self.scratch.extend_from_slice(self.suffix); 192 } 193 self.scratch.extend_from_slice(&progress_message[i..i + 1]); 194 195 ui.status().write_all(&self.scratch)?; 196 self.scratch.clear(); 197 198 index = i + 1; 199 } 200 201 // Add leftover message to "scratch" buffer to be printed in next call. 202 if index < progress_message.len() && progress_message[index] != 0 { 203 if self.scratch.is_empty() { 204 self.scratch.extend_from_slice(self.display_prefix); 205 } 206 self.scratch.extend_from_slice(&progress_message[index..]); 207 } 208 209 Ok(()) 210 } 211 212 pub fn flush(&mut self, ui: &Ui) -> std::io::Result<()> { 213 if !self.scratch.is_empty() { 214 self.scratch.push(b'\n'); 215 ui.status().write_all(&self.scratch)?; 216 self.scratch.clear(); 217 } 218 219 Ok(()) 220 } 221} 222 223type SidebandProgressCallback<'a> = &'a mut dyn FnMut(&[u8]); 224 225pub fn with_remote_git_callbacks<T>( 226 ui: &Ui, 227 sideband_progress_callback: Option<SidebandProgressCallback<'_>>, 228 f: impl FnOnce(git::RemoteCallbacks<'_>) -> T, 229) -> T { 230 let mut callbacks = git::RemoteCallbacks::default(); 231 let mut progress_callback = None; 232 if let Some(mut output) = ui.progress_output() { 233 let mut progress = Progress::new(Instant::now()); 234 progress_callback = Some(move |x: &git::Progress| { 235 _ = progress.update(Instant::now(), x, &mut output); 236 }); 237 } 238 callbacks.progress = progress_callback 239 .as_mut() 240 .map(|x| x as &mut dyn FnMut(&git::Progress)); 241 callbacks.sideband_progress = sideband_progress_callback.map(|x| x as &mut dyn FnMut(&[u8])); 242 let mut get_ssh_keys = get_ssh_keys; // Coerce to unit fn type 243 callbacks.get_ssh_keys = Some(&mut get_ssh_keys); 244 let mut get_pw = 245 |url: &str, _username: &str| pinentry_get_pw(url).or_else(|| terminal_get_pw(ui, url)); 246 callbacks.get_password = Some(&mut get_pw); 247 let mut get_user_pw = 248 |url: &str| Some((terminal_get_username(ui, url)?, terminal_get_pw(ui, url)?)); 249 callbacks.get_username_password = Some(&mut get_user_pw); 250 f(callbacks) 251} 252 253pub fn print_git_import_stats( 254 ui: &mut Ui, 255 repo: &dyn Repo, 256 stats: &GitImportStats, 257 show_ref_stats: bool, 258) -> Result<(), CommandError> { 259 let Some(mut formatter) = ui.status_formatter() else { 260 return Ok(()); 261 }; 262 if show_ref_stats { 263 let refs_stats = stats 264 .changed_remote_refs 265 .iter() 266 .map(|(ref_name, (remote_ref, ref_target))| { 267 RefStatus::new(ref_name, remote_ref, ref_target, repo) 268 }) 269 .collect_vec(); 270 271 let has_both_ref_kinds = refs_stats 272 .iter() 273 .any(|x| matches!(x.ref_kind, RefKind::Branch)) 274 && refs_stats 275 .iter() 276 .any(|x| matches!(x.ref_kind, RefKind::Tag)); 277 278 let max_width = refs_stats.iter().map(|x| x.ref_name.width()).max(); 279 if let Some(max_width) = max_width { 280 for status in refs_stats { 281 status.output(max_width, has_both_ref_kinds, &mut *formatter)?; 282 } 283 } 284 } 285 286 if !stats.abandoned_commits.is_empty() { 287 writeln!( 288 formatter, 289 "Abandoned {} commits that are no longer reachable.", 290 stats.abandoned_commits.len() 291 )?; 292 } 293 294 Ok(()) 295} 296 297struct RefStatus { 298 ref_kind: RefKind, 299 ref_name: String, 300 tracking_status: TrackingStatus, 301 import_status: ImportStatus, 302} 303 304impl RefStatus { 305 fn new( 306 ref_name: &RefName, 307 remote_ref: &RemoteRef, 308 ref_target: &RefTarget, 309 repo: &dyn Repo, 310 ) -> Self { 311 let (ref_name, ref_kind, tracking_status) = match ref_name { 312 RefName::RemoteBranch { branch, remote } => ( 313 format!("{branch}@{remote}"), 314 RefKind::Branch, 315 if repo.view().get_remote_branch(branch, remote).is_tracking() { 316 TrackingStatus::Tracked 317 } else { 318 TrackingStatus::Untracked 319 }, 320 ), 321 RefName::Tag(tag) => (tag.clone(), RefKind::Tag, TrackingStatus::NotApplicable), 322 RefName::LocalBranch(branch) => { 323 (branch.clone(), RefKind::Branch, TrackingStatus::Tracked) 324 } 325 }; 326 327 let import_status = match (remote_ref.target.is_absent(), ref_target.is_absent()) { 328 (true, false) => ImportStatus::New, 329 (false, true) => ImportStatus::Deleted, 330 _ => ImportStatus::Updated, 331 }; 332 333 Self { 334 ref_name, 335 tracking_status, 336 import_status, 337 ref_kind, 338 } 339 } 340 341 fn output( 342 &self, 343 max_ref_name_width: usize, 344 has_both_ref_kinds: bool, 345 out: &mut dyn Formatter, 346 ) -> std::io::Result<()> { 347 let tracking_status = match self.tracking_status { 348 TrackingStatus::Tracked => "tracked", 349 TrackingStatus::Untracked => "untracked", 350 TrackingStatus::NotApplicable => "", 351 }; 352 353 let import_status = match self.import_status { 354 ImportStatus::New => "new", 355 ImportStatus::Deleted => "deleted", 356 ImportStatus::Updated => "updated", 357 }; 358 359 let ref_name_display_width = self.ref_name.width(); 360 let pad_width = max_ref_name_width.saturating_sub(ref_name_display_width); 361 let padded_ref_name = format!("{}{:>pad_width$}", self.ref_name, "", pad_width = pad_width); 362 363 let ref_kind = match self.ref_kind { 364 RefKind::Branch => "branch: ", 365 RefKind::Tag if !has_both_ref_kinds => "tag: ", 366 RefKind::Tag => "tag: ", 367 }; 368 369 write!(out, "{ref_kind}")?; 370 write!(out.labeled("branch"), "{padded_ref_name}")?; 371 writeln!(out, " [{import_status}] {tracking_status}") 372 } 373} 374 375enum RefKind { 376 Branch, 377 Tag, 378} 379 380enum TrackingStatus { 381 Tracked, 382 Untracked, 383 NotApplicable, // for tags 384} 385 386enum ImportStatus { 387 New, 388 Deleted, 389 Updated, 390} 391 392pub fn print_failed_git_export( 393 ui: &Ui, 394 failed_branches: &[FailedRefExport], 395) -> Result<(), std::io::Error> { 396 if !failed_branches.is_empty() { 397 writeln!(ui.warning_default(), "Failed to export some branches:")?; 398 let mut formatter = ui.stderr_formatter(); 399 for FailedRefExport { name, reason } in failed_branches { 400 write!(formatter, " ")?; 401 write!(formatter.labeled("branch"), "{name}")?; 402 for err in iter::successors(Some(reason as &dyn error::Error), |err| err.source()) { 403 write!(formatter, ": {err}")?; 404 } 405 writeln!(formatter)?; 406 } 407 drop(formatter); 408 if failed_branches 409 .iter() 410 .any(|failed| matches!(failed.reason, FailedRefExportReason::FailedToSet(_))) 411 { 412 if let Some(mut writer) = ui.hint_default() { 413 writeln!( 414 writer, 415 r#"Git doesn't allow a branch name that looks like a parent directory of 416another (e.g. `foo` and `foo/bar`). Try to rename the branches that failed to 417export or their "parent" branches."#, 418 )?; 419 } 420 } 421 } 422 Ok(()) 423} 424 425/// Expands "~/" to "$HOME/" as Git seems to do for e.g. core.excludesFile. 426pub fn expand_git_path(path_str: &str) -> PathBuf { 427 if let Some(remainder) = path_str.strip_prefix("~/") { 428 if let Ok(home_dir_str) = std::env::var("HOME") { 429 return PathBuf::from(home_dir_str).join(remainder); 430 } 431 } 432 PathBuf::from(path_str) 433}