1use std::error;
18use std::io;
19use std::io::Read as _;
20use std::io::Write as _;
21use std::iter;
22use std::mem;
23use std::path::Path;
24use std::path::PathBuf;
25use std::process::Stdio;
26use std::time::Duration;
27use std::time::Instant;
28
29use crossterm::terminal::Clear;
30use crossterm::terminal::ClearType;
31use indoc::writedoc;
32use itertools::Itertools as _;
33#[cfg(feature = "git2")]
34use jj_lib::config::ConfigGetResultExt as _;
35use jj_lib::fmt_util::binary_prefix;
36use jj_lib::git;
37use jj_lib::git::FailedRefExportReason;
38use jj_lib::git::GitExportStats;
39use jj_lib::git::GitImportStats;
40use jj_lib::git::GitRefKind;
41use jj_lib::op_store::RefTarget;
42use jj_lib::op_store::RemoteRef;
43use jj_lib::ref_name::RemoteRefSymbol;
44use jj_lib::repo::ReadonlyRepo;
45use jj_lib::repo::Repo;
46#[cfg(feature = "git2")]
47use jj_lib::settings::UserSettings;
48use jj_lib::workspace::Workspace;
49use unicode_width::UnicodeWidthStr as _;
50
51use crate::cleanup_guard::CleanupGuard;
52use crate::command_error::cli_error;
53use crate::command_error::user_error;
54use crate::command_error::CommandError;
55use crate::formatter::Formatter;
56use crate::ui::ProgressOutput;
57use crate::ui::Ui;
58
59pub fn is_colocated_git_workspace(workspace: &Workspace, repo: &ReadonlyRepo) -> bool {
60 let Ok(git_backend) = git::get_git_backend(repo.store()) else {
61 return false;
62 };
63 let Some(git_workdir) = git_backend.git_workdir() else {
64 return false; };
66 if git_workdir == workspace.workspace_root() {
67 return true;
68 }
69 let Ok(dot_git_path) = dunce::canonicalize(workspace.workspace_root().join(".git")) else {
72 return false;
73 };
74 dunce::canonicalize(git_workdir).ok().as_deref() == dot_git_path.parent()
75}
76
77pub fn absolute_git_url(cwd: &Path, source: &str) -> Result<String, CommandError> {
79 let mut url = gix::url::parse(source.as_ref()).map_err(cli_error)?;
84 url.canonicalize(cwd).map_err(user_error)?;
85 if url.scheme == gix::url::Scheme::File {
88 url.path = gix::path::to_unix_separators_on_windows(mem::take(&mut url.path)).into_owned();
89 }
90 Ok(String::from_utf8(url.to_bstring().into()).unwrap_or_else(|_| source.to_owned()))
92}
93
94fn terminal_get_username(ui: &Ui, url: &str) -> Option<String> {
95 ui.prompt(&format!("Username for {url}")).ok()
96}
97
98fn terminal_get_pw(ui: &Ui, url: &str) -> Option<String> {
99 ui.prompt_password(&format!("Passphrase for {url}")).ok()
100}
101
102fn pinentry_get_pw(url: &str) -> Option<String> {
103 fn decode_assuan_data(encoded: &str) -> Option<String> {
105 let encoded = encoded.as_bytes();
106 let mut decoded = Vec::with_capacity(encoded.len());
107 let mut i = 0;
108 while i < encoded.len() {
109 if encoded[i] != b'%' {
110 decoded.push(encoded[i]);
111 i += 1;
112 continue;
113 }
114 i += 1;
115 let byte =
116 u8::from_str_radix(std::str::from_utf8(encoded.get(i..i + 2)?).ok()?, 16).ok()?;
117 decoded.push(byte);
118 i += 2;
119 }
120 String::from_utf8(decoded).ok()
121 }
122
123 let mut pinentry = std::process::Command::new("pinentry")
124 .stdin(Stdio::piped())
125 .stdout(Stdio::piped())
126 .spawn()
127 .ok()?;
128 let mut interact = || -> std::io::Result<_> {
129 #[rustfmt::skip]
130 let req = format!(
131 "SETTITLE jj passphrase\n\
132 SETDESC Enter passphrase for {url}\n\
133 SETPROMPT Passphrase:\n\
134 GETPIN\n"
135 );
136 pinentry.stdin.take().unwrap().write_all(req.as_bytes())?;
137 let mut out = String::new();
138 pinentry.stdout.take().unwrap().read_to_string(&mut out)?;
139 Ok(out)
140 };
141 let maybe_out = interact();
142 _ = pinentry.wait();
143 for line in maybe_out.ok()?.split('\n') {
144 if !line.starts_with("D ") {
145 continue;
146 }
147 let (_, encoded) = line.split_at(2);
148 return decode_assuan_data(encoded);
149 }
150 None
151}
152
153#[tracing::instrument]
154fn get_ssh_keys(_username: &str) -> Vec<PathBuf> {
155 let mut paths = vec![];
156 if let Ok(home_dir) = etcetera::home_dir() {
157 let ssh_dir = home_dir.join(".ssh");
158 for filename in ["id_ed25519_sk", "id_ed25519", "id_rsa"] {
159 let key_path = ssh_dir.join(filename);
160 if key_path.is_file() {
161 tracing::info!(path = ?key_path, "found ssh key");
162 paths.push(key_path);
163 }
164 }
165 }
166 if paths.is_empty() {
167 tracing::info!("no ssh key found");
168 }
169 paths
170}
171
172pub struct GitSidebandProgressMessageWriter {
174 display_prefix: &'static [u8],
175 suffix: &'static [u8],
176 scratch: Vec<u8>,
177}
178
179impl GitSidebandProgressMessageWriter {
180 pub fn new(ui: &Ui) -> Self {
181 let is_terminal = ui.use_progress_indicator();
182
183 GitSidebandProgressMessageWriter {
184 display_prefix: "remote: ".as_bytes(),
185 suffix: if is_terminal { "\x1B[K" } else { " " }.as_bytes(),
186 scratch: Vec::new(),
187 }
188 }
189
190 pub fn write(&mut self, ui: &Ui, progress_message: &[u8]) -> std::io::Result<()> {
191 let mut index = 0;
192 loop {
194 let Some(i) = progress_message[index..]
195 .iter()
196 .position(|&c| c == b'\r' || c == b'\n')
197 .map(|i| index + i)
198 else {
199 break;
200 };
201 let line_length = i - index;
202
203 if !self.scratch.is_empty() && line_length == 0 {
208 self.scratch.extend_from_slice(self.suffix);
209 }
210
211 if self.scratch.is_empty() {
212 self.scratch.extend_from_slice(self.display_prefix);
213 }
214
215 if line_length > 0 {
222 self.scratch.extend_from_slice(&progress_message[index..i]);
223 self.scratch.extend_from_slice(self.suffix);
224 }
225 self.scratch.extend_from_slice(&progress_message[i..i + 1]);
226
227 ui.status().write_all(&self.scratch)?;
228 self.scratch.clear();
229
230 index = i + 1;
231 }
232
233 if index < progress_message.len() {
235 if self.scratch.is_empty() {
236 self.scratch.extend_from_slice(self.display_prefix);
237 }
238 self.scratch.extend_from_slice(&progress_message[index..]);
239 }
240
241 Ok(())
242 }
243
244 pub fn flush(&mut self, ui: &Ui) -> std::io::Result<()> {
245 if !self.scratch.is_empty() {
246 self.scratch.push(b'\n');
247 ui.status().write_all(&self.scratch)?;
248 self.scratch.clear();
249 }
250
251 Ok(())
252 }
253}
254
255pub fn with_remote_git_callbacks<T>(ui: &Ui, f: impl FnOnce(git::RemoteCallbacks<'_>) -> T) -> T {
256 let mut callbacks = git::RemoteCallbacks::default();
257
258 let mut progress_callback;
259 if let Some(mut output) = ui.progress_output() {
260 let mut progress = Progress::new(Instant::now());
261 progress_callback = move |x: &git::Progress| {
262 _ = progress.update(Instant::now(), x, &mut output);
263 };
264 callbacks.progress = Some(&mut progress_callback);
265 }
266
267 let mut sideband_progress_writer = GitSidebandProgressMessageWriter::new(ui);
268 let mut sideband_progress_callback = |progress_message: &[u8]| {
269 _ = sideband_progress_writer.write(ui, progress_message);
270 };
271 callbacks.sideband_progress = Some(&mut sideband_progress_callback);
272
273 let mut get_ssh_keys = get_ssh_keys; callbacks.get_ssh_keys = Some(&mut get_ssh_keys);
275 let mut get_pw =
276 |url: &str, _username: &str| pinentry_get_pw(url).or_else(|| terminal_get_pw(ui, url));
277 callbacks.get_password = Some(&mut get_pw);
278 let mut get_user_pw =
279 |url: &str| Some((terminal_get_username(ui, url)?, terminal_get_pw(ui, url)?));
280 callbacks.get_username_password = Some(&mut get_user_pw);
281
282 let result = f(callbacks);
283 _ = sideband_progress_writer.flush(ui);
284 result
285}
286
287pub fn print_git_import_stats(
288 ui: &Ui,
289 repo: &dyn Repo,
290 stats: &GitImportStats,
291 show_ref_stats: bool,
292) -> Result<(), CommandError> {
293 let Some(mut formatter) = ui.status_formatter() else {
294 return Ok(());
295 };
296 if show_ref_stats {
297 let refs_stats = [
298 (GitRefKind::Bookmark, &stats.changed_remote_bookmarks),
299 (GitRefKind::Tag, &stats.changed_remote_tags),
300 ]
301 .into_iter()
302 .flat_map(|(kind, changes)| {
303 changes
304 .iter()
305 .map(move |(symbol, (remote_ref, ref_target))| {
306 RefStatus::new(kind, symbol.as_ref(), remote_ref, ref_target, repo)
307 })
308 })
309 .collect_vec();
310
311 let has_both_ref_kinds =
312 !stats.changed_remote_bookmarks.is_empty() && !stats.changed_remote_tags.is_empty();
313 let max_width = refs_stats.iter().map(|x| x.symbol.width()).max();
314 if let Some(max_width) = max_width {
315 for status in refs_stats {
316 status.output(max_width, has_both_ref_kinds, &mut *formatter)?;
317 }
318 }
319 }
320
321 if !stats.abandoned_commits.is_empty() {
322 writeln!(
323 formatter,
324 "Abandoned {} commits that are no longer reachable.",
325 stats.abandoned_commits.len()
326 )?;
327 }
328
329 if !stats.failed_ref_names.is_empty() {
330 writeln!(ui.warning_default(), "Failed to import some Git refs:")?;
331 let mut formatter = ui.stderr_formatter();
332 for name in &stats.failed_ref_names {
333 write!(formatter, " ")?;
334 write!(formatter.labeled("git_ref"), "{name}")?;
335 writeln!(formatter)?;
336 }
337 }
338 if stats
339 .failed_ref_names
340 .iter()
341 .any(|name| name.starts_with(git::RESERVED_REMOTE_REF_NAMESPACE.as_bytes()))
342 {
343 writedoc!(
344 ui.hint_default(),
345 "
346 Git remote named '{name}' is reserved for local Git repository.
347 Use `jj git remote rename` to give a different name.
348 ",
349 name = git::REMOTE_NAME_FOR_LOCAL_GIT_REPO.as_symbol(),
350 )?;
351 }
352
353 Ok(())
354}
355
356pub struct Progress {
357 next_print: Instant,
358 rate: RateEstimate,
359 buffer: String,
360 guard: Option<CleanupGuard>,
361}
362
363impl Progress {
364 pub fn new(now: Instant) -> Self {
365 Self {
366 next_print: now + crate::progress::INITIAL_DELAY,
367 rate: RateEstimate::new(),
368 buffer: String::new(),
369 guard: None,
370 }
371 }
372
373 pub fn update<W: std::io::Write>(
374 &mut self,
375 now: Instant,
376 progress: &git::Progress,
377 output: &mut ProgressOutput<W>,
378 ) -> io::Result<()> {
379 use std::fmt::Write as _;
380
381 if progress.overall == 1.0 {
382 write!(output, "\r{}", Clear(ClearType::CurrentLine))?;
383 output.flush()?;
384 return Ok(());
385 }
386
387 let rate = progress
388 .bytes_downloaded
389 .and_then(|x| self.rate.update(now, x));
390 if now < self.next_print {
391 return Ok(());
392 }
393 self.next_print = now + Duration::from_secs(1) / crate::progress::UPDATE_HZ;
394 if self.guard.is_none() {
395 let guard = output.output_guard(crossterm::cursor::Show.to_string());
396 let guard = CleanupGuard::new(move || {
397 drop(guard);
398 });
399 _ = write!(output, "{}", crossterm::cursor::Hide);
400 self.guard = Some(guard);
401 }
402
403 self.buffer.clear();
404 self.buffer.push('\r');
406 let control_chars = self.buffer.len();
407 write!(self.buffer, "{: >3.0}% ", 100.0 * progress.overall).unwrap();
408 if let Some(total) = progress.bytes_downloaded {
409 let (scaled, prefix) = binary_prefix(total as f32);
410 write!(self.buffer, "{scaled: >5.1} {prefix}B ").unwrap();
411 }
412 if let Some(estimate) = rate {
413 let (scaled, prefix) = binary_prefix(estimate);
414 write!(self.buffer, "at {scaled: >5.1} {prefix}B/s ").unwrap();
415 }
416
417 let bar_width = output
418 .term_width()
419 .map(usize::from)
420 .unwrap_or(0)
421 .saturating_sub(self.buffer.len() - control_chars + 2);
422 self.buffer.push('[');
423 draw_progress(progress.overall, &mut self.buffer, bar_width);
424 self.buffer.push(']');
425
426 write!(self.buffer, "{}", Clear(ClearType::UntilNewLine)).unwrap();
427 self.buffer.push('\r');
430 write!(output, "{}", self.buffer)?;
431 output.flush()?;
432 Ok(())
433 }
434}
435
436fn draw_progress(progress: f32, buffer: &mut String, width: usize) {
437 const CHARS: [char; 9] = [' ', '▏', '▎', '▍', '▌', '▋', '▊', '▉', '█'];
438 const RESOLUTION: usize = CHARS.len() - 1;
439 let ticks = (width as f32 * progress.clamp(0.0, 1.0) * RESOLUTION as f32).round() as usize;
440 let whole = ticks / RESOLUTION;
441 for _ in 0..whole {
442 buffer.push(CHARS[CHARS.len() - 1]);
443 }
444 if whole < width {
445 let fraction = ticks % RESOLUTION;
446 buffer.push(CHARS[fraction]);
447 }
448 for _ in (whole + 1)..width {
449 buffer.push(CHARS[0]);
450 }
451}
452
453struct RateEstimate {
454 state: Option<RateEstimateState>,
455}
456
457impl RateEstimate {
458 pub fn new() -> Self {
459 RateEstimate { state: None }
460 }
461
462 pub fn update(&mut self, now: Instant, total: u64) -> Option<f32> {
464 if let Some(ref mut state) = self.state {
465 return Some(state.update(now, total));
466 }
467
468 self.state = Some(RateEstimateState {
469 total,
470 avg_rate: None,
471 last_sample: now,
472 });
473 None
474 }
475}
476
477struct RateEstimateState {
478 total: u64,
479 avg_rate: Option<f32>,
480 last_sample: Instant,
481}
482
483impl RateEstimateState {
484 fn update(&mut self, now: Instant, total: u64) -> f32 {
485 let delta = total - self.total;
486 self.total = total;
487 let dt = now - self.last_sample;
488 self.last_sample = now;
489 let sample = delta as f32 / dt.as_secs_f32();
490 match self.avg_rate {
491 None => *self.avg_rate.insert(sample),
492 Some(ref mut avg_rate) => {
493 const TIME_WINDOW: f32 = 2.0;
496 let alpha = 1.0 - (-dt.as_secs_f32() / TIME_WINDOW).exp();
497 *avg_rate += alpha * (sample - *avg_rate);
498 *avg_rate
499 }
500 }
501 }
502}
503
504struct RefStatus {
505 ref_kind: GitRefKind,
506 symbol: String,
507 tracking_status: TrackingStatus,
508 import_status: ImportStatus,
509}
510
511impl RefStatus {
512 fn new(
513 ref_kind: GitRefKind,
514 symbol: RemoteRefSymbol<'_>,
515 remote_ref: &RemoteRef,
516 ref_target: &RefTarget,
517 repo: &dyn Repo,
518 ) -> Self {
519 let tracking_status = match ref_kind {
520 GitRefKind::Bookmark => {
521 if repo.view().get_remote_bookmark(symbol).is_tracked() {
522 TrackingStatus::Tracked
523 } else {
524 TrackingStatus::Untracked
525 }
526 }
527 GitRefKind::Tag => TrackingStatus::NotApplicable,
528 };
529
530 let import_status = match (remote_ref.target.is_absent(), ref_target.is_absent()) {
531 (true, false) => ImportStatus::New,
532 (false, true) => ImportStatus::Deleted,
533 _ => ImportStatus::Updated,
534 };
535
536 Self {
537 symbol: symbol.to_string(),
538 tracking_status,
539 import_status,
540 ref_kind,
541 }
542 }
543
544 fn output(
545 &self,
546 max_symbol_width: usize,
547 has_both_ref_kinds: bool,
548 out: &mut dyn Formatter,
549 ) -> std::io::Result<()> {
550 let tracking_status = match self.tracking_status {
551 TrackingStatus::Tracked => "tracked",
552 TrackingStatus::Untracked => "untracked",
553 TrackingStatus::NotApplicable => "",
554 };
555
556 let import_status = match self.import_status {
557 ImportStatus::New => "new",
558 ImportStatus::Deleted => "deleted",
559 ImportStatus::Updated => "updated",
560 };
561
562 let symbol_width = self.symbol.width();
563 let pad_width = max_symbol_width.saturating_sub(symbol_width);
564 let padded_symbol = format!("{}{:>pad_width$}", self.symbol, "", pad_width = pad_width);
565
566 let ref_kind = match self.ref_kind {
567 GitRefKind::Bookmark => "bookmark: ",
568 GitRefKind::Tag if !has_both_ref_kinds => "tag: ",
569 GitRefKind::Tag => "tag: ",
570 };
571
572 write!(out, "{ref_kind}")?;
573 write!(out.labeled("bookmark"), "{padded_symbol}")?;
574 writeln!(out, " [{import_status}] {tracking_status}")
575 }
576}
577
578enum TrackingStatus {
579 Tracked,
580 Untracked,
581 NotApplicable, }
583
584enum ImportStatus {
585 New,
586 Deleted,
587 Updated,
588}
589
590pub fn print_git_export_stats(ui: &Ui, stats: &GitExportStats) -> Result<(), std::io::Error> {
591 if !stats.failed_bookmarks.is_empty() {
592 writeln!(ui.warning_default(), "Failed to export some bookmarks:")?;
593 let mut formatter = ui.stderr_formatter();
594 for (symbol, reason) in &stats.failed_bookmarks {
595 write!(formatter, " ")?;
596 write!(formatter.labeled("bookmark"), "{symbol}")?;
597 for err in iter::successors(Some(reason as &dyn error::Error), |err| err.source()) {
598 write!(formatter, ": {err}")?;
599 }
600 writeln!(formatter)?;
601 }
602 drop(formatter);
603 if stats
604 .failed_bookmarks
605 .iter()
606 .any(|(_, reason)| matches!(reason, FailedRefExportReason::FailedToSet(_)))
607 {
608 writeln!(
609 ui.hint_default(),
610 r#"Git doesn't allow a branch name that looks like a parent directory of
611another (e.g. `foo` and `foo/bar`). Try to rename the bookmarks that failed to
612export or their "parent" bookmarks."#,
613 )?;
614 }
615 }
616 Ok(())
617}
618
619#[cfg(feature = "git2")]
620pub fn print_git2_deprecation_warning(
621 ui: &Ui,
622 settings: &UserSettings,
623) -> Result<(), CommandError> {
624 if !settings.git_settings()?.subprocess
625 && !settings
626 .get("debug.suppress-git2-deprecation-warning")
627 .optional()?
628 .unwrap_or(false)
629 {
630 writeln!(
631 ui.warning_default(),
632 "`git.subprocess = false` will be removed in 0.30; please report any issues you have \
633 with the default.",
634 )?;
635 }
636 Ok(())
637}
638
639#[cfg(test)]
640mod tests {
641 use std::path::MAIN_SEPARATOR;
642
643 use insta::assert_snapshot;
644
645 use super::*;
646
647 #[test]
648 fn test_absolute_git_url() {
649 let temp_dir = testutils::new_temp_dir();
652 let cwd = dunce::canonicalize(temp_dir.path()).unwrap();
653 let cwd_slash = cwd.to_str().unwrap().replace(MAIN_SEPARATOR, "/");
654
655 assert_eq!(
657 absolute_git_url(&cwd, "foo").unwrap(),
658 format!("{cwd_slash}/foo")
659 );
660 assert_eq!(
661 absolute_git_url(&cwd, r"foo\bar").unwrap(),
662 if cfg!(windows) {
663 format!("{cwd_slash}/foo/bar")
664 } else {
665 format!(r"{cwd_slash}/foo\bar")
666 }
667 );
668 assert_eq!(
669 absolute_git_url(&cwd.join("bar"), &format!("{cwd_slash}/foo")).unwrap(),
670 format!("{cwd_slash}/foo")
671 );
672
673 assert_eq!(
675 absolute_git_url(&cwd, "git@example.org:foo/bar.git").unwrap(),
676 "git@example.org:foo/bar.git"
677 );
678 assert_eq!(
680 absolute_git_url(&cwd, "https://example.org/foo.git").unwrap(),
681 "https://example.org/foo.git"
682 );
683 assert_eq!(
685 absolute_git_url(&cwd, "custom://example.org/foo.git").unwrap(),
686 "custom://example.org/foo.git"
687 );
688 assert_eq!(
690 absolute_git_url(&cwd, "https://user:pass@example.org/").unwrap(),
691 "https://user:pass@example.org/"
692 );
693 }
694
695 #[test]
696 fn test_bar() {
697 let mut buf = String::new();
698 draw_progress(0.0, &mut buf, 10);
699 assert_eq!(buf, " ");
700 buf.clear();
701 draw_progress(1.0, &mut buf, 10);
702 assert_eq!(buf, "██████████");
703 buf.clear();
704 draw_progress(0.5, &mut buf, 10);
705 assert_eq!(buf, "█████ ");
706 buf.clear();
707 draw_progress(0.54, &mut buf, 10);
708 assert_eq!(buf, "█████▍ ");
709 buf.clear();
710 }
711
712 #[test]
713 fn test_update() {
714 let start = Instant::now();
715 let mut progress = Progress::new(start);
716 let mut current_time = start;
717 let mut update = |duration, overall| -> String {
718 current_time += duration;
719 let mut buf = vec![];
720 let mut output = ProgressOutput::for_test(&mut buf, 25);
721 progress
722 .update(
723 current_time,
724 &jj_lib::git::Progress {
725 bytes_downloaded: None,
726 overall,
727 },
728 &mut output,
729 )
730 .unwrap();
731 String::from_utf8(buf).unwrap()
732 };
733 assert_snapshot!(update(crate::progress::INITIAL_DELAY - Duration::from_millis(1), 0.1), @"");
735 assert_snapshot!(update(Duration::from_millis(1), 0.10), @"\u{1b}[?25l\r 10% [█▊ ]\u{1b}[K");
736 assert_snapshot!(update(Duration::from_millis(10), 0.11), @"");
738 assert_snapshot!(update(Duration::from_millis(10), 0.12), @"");
739 assert_snapshot!(update(Duration::from_millis(10), 0.13), @"");
740 assert_snapshot!(update(Duration::from_millis(100), 0.30), @" 30% [█████▍ ][K");
742 assert_snapshot!(update(Duration::from_millis(30), 0.40), @"");
745 }
746}