1use std::error;
18use std::io;
19use std::io::Write as _;
20use std::iter;
21use std::mem;
22use std::path::Path;
23use std::time::Duration;
24use std::time::Instant;
25
26use crossterm::terminal::Clear;
27use crossterm::terminal::ClearType;
28use indoc::writedoc;
29use itertools::Itertools as _;
30use jj_lib::fmt_util::binary_prefix;
31use jj_lib::git;
32use jj_lib::git::FailedRefExportReason;
33use jj_lib::git::GitExportStats;
34use jj_lib::git::GitImportOptions;
35use jj_lib::git::GitImportStats;
36use jj_lib::git::GitRefKind;
37use jj_lib::git::GitSettings;
38use jj_lib::op_store::RefTarget;
39use jj_lib::op_store::RemoteRef;
40use jj_lib::ref_name::RemoteRefSymbol;
41use jj_lib::repo::ReadonlyRepo;
42use jj_lib::repo::Repo;
43use jj_lib::settings::RemoteSettingsMap;
44use jj_lib::workspace::Workspace;
45use unicode_width::UnicodeWidthStr as _;
46
47use crate::cleanup_guard::CleanupGuard;
48use crate::command_error::CommandError;
49use crate::command_error::cli_error;
50use crate::command_error::user_error;
51use crate::formatter::Formatter;
52use crate::formatter::FormatterExt as _;
53use crate::revset_util::parse_remote_auto_track_bookmarks_map;
54use crate::ui::ProgressOutput;
55use crate::ui::Ui;
56
57pub fn is_colocated_git_workspace(workspace: &Workspace, repo: &ReadonlyRepo) -> bool {
58 let Ok(git_backend) = git::get_git_backend(repo.store()) else {
59 return false;
60 };
61 let Some(git_workdir) = git_backend.git_workdir() else {
62 return false; };
64 if git_workdir == workspace.workspace_root() {
65 return true;
66 }
67 let Ok(dot_git_path) = dunce::canonicalize(workspace.workspace_root().join(".git")) else {
70 return false;
71 };
72 dunce::canonicalize(git_workdir).ok().as_deref() == dot_git_path.parent()
73}
74
75pub fn absolute_git_url(cwd: &Path, source: &str) -> Result<String, CommandError> {
77 let mut url = gix::url::parse(source.as_ref()).map_err(cli_error)?;
82 url.canonicalize(cwd).map_err(user_error)?;
83 if url.scheme == gix::url::Scheme::File {
86 url.path = gix::path::to_unix_separators_on_windows(mem::take(&mut url.path)).into_owned();
87 }
88 Ok(String::from_utf8(url.to_bstring().into()).unwrap_or_else(|_| source.to_owned()))
90}
91
92pub struct GitSidebandProgressMessageWriter {
94 display_prefix: &'static [u8],
95 suffix: &'static [u8],
96 scratch: Vec<u8>,
97}
98
99impl GitSidebandProgressMessageWriter {
100 pub fn new(ui: &Ui) -> Self {
101 let is_terminal = ui.use_progress_indicator();
102
103 Self {
104 display_prefix: "remote: ".as_bytes(),
105 suffix: if is_terminal { "\x1B[K" } else { " " }.as_bytes(),
106 scratch: Vec::new(),
107 }
108 }
109
110 pub fn write(&mut self, ui: &Ui, progress_message: &[u8]) -> std::io::Result<()> {
111 let mut index = 0;
112 loop {
114 let Some(i) = progress_message[index..]
115 .iter()
116 .position(|&c| c == b'\r' || c == b'\n')
117 .map(|i| index + i)
118 else {
119 break;
120 };
121 let line_length = i - index;
122
123 if !self.scratch.is_empty() && line_length == 0 {
128 self.scratch.extend_from_slice(self.suffix);
129 }
130
131 if self.scratch.is_empty() {
132 self.scratch.extend_from_slice(self.display_prefix);
133 }
134
135 if line_length > 0 {
142 self.scratch.extend_from_slice(&progress_message[index..i]);
143 self.scratch.extend_from_slice(self.suffix);
144 }
145 self.scratch.extend_from_slice(&progress_message[i..i + 1]);
146
147 ui.status().write_all(&self.scratch)?;
148 self.scratch.clear();
149
150 index = i + 1;
151 }
152
153 if index < progress_message.len() {
155 if self.scratch.is_empty() {
156 self.scratch.extend_from_slice(self.display_prefix);
157 }
158 self.scratch.extend_from_slice(&progress_message[index..]);
159 }
160
161 Ok(())
162 }
163
164 pub fn flush(&mut self, ui: &Ui) -> std::io::Result<()> {
165 if !self.scratch.is_empty() {
166 self.scratch.push(b'\n');
167 ui.status().write_all(&self.scratch)?;
168 self.scratch.clear();
169 }
170
171 Ok(())
172 }
173}
174
175pub fn with_remote_git_callbacks<T>(ui: &Ui, f: impl FnOnce(git::RemoteCallbacks<'_>) -> T) -> T {
176 let mut callbacks = git::RemoteCallbacks::default();
177
178 let mut progress_callback;
179 if let Some(mut output) = ui.progress_output() {
180 let mut progress = Progress::new(Instant::now());
181 progress_callback = move |x: &git::Progress| {
182 progress.update(Instant::now(), x, &mut output).ok();
183 };
184 callbacks.progress = Some(&mut progress_callback);
185 }
186
187 let mut sideband_progress_writer = GitSidebandProgressMessageWriter::new(ui);
188 let mut sideband_progress_callback = |progress_message: &[u8]| {
189 sideband_progress_writer.write(ui, progress_message).ok();
190 };
191 callbacks.sideband_progress = Some(&mut sideband_progress_callback);
192
193 let result = f(callbacks);
194 sideband_progress_writer.flush(ui).ok();
195 result
196}
197
198pub fn load_git_import_options(
199 ui: &Ui,
200 git_settings: &GitSettings,
201 remote_settings: &RemoteSettingsMap,
202) -> Result<GitImportOptions, CommandError> {
203 Ok(GitImportOptions {
204 auto_local_bookmark: git_settings.auto_local_bookmark,
205 abandon_unreachable_commits: git_settings.abandon_unreachable_commits,
206 remote_auto_track_bookmarks: parse_remote_auto_track_bookmarks_map(ui, remote_settings)?,
207 })
208}
209
210pub fn print_git_import_stats(
211 ui: &Ui,
212 repo: &dyn Repo,
213 stats: &GitImportStats,
214 show_ref_stats: bool,
215) -> Result<(), CommandError> {
216 let Some(mut formatter) = ui.status_formatter() else {
217 return Ok(());
218 };
219 if show_ref_stats {
220 for (kind, changes) in [
221 (GitRefKind::Bookmark, &stats.changed_remote_bookmarks),
222 (GitRefKind::Tag, &stats.changed_remote_tags),
223 ] {
224 let refs_stats = changes
225 .iter()
226 .map(|(symbol, (remote_ref, ref_target))| {
227 RefStatus::new(kind, symbol.as_ref(), remote_ref, ref_target, repo)
228 })
229 .collect_vec();
230 let Some(max_width) = refs_stats.iter().map(|x| x.symbol.width()).max() else {
231 continue;
232 };
233 for status in refs_stats {
234 status.output(max_width, &mut *formatter)?;
235 }
236 }
237 }
238
239 if !stats.abandoned_commits.is_empty() {
240 writeln!(
241 formatter,
242 "Abandoned {} commits that are no longer reachable.",
243 stats.abandoned_commits.len()
244 )?;
245 }
246
247 if !stats.failed_ref_names.is_empty() {
248 writeln!(ui.warning_default(), "Failed to import some Git refs:")?;
249 let mut formatter = ui.stderr_formatter();
250 for name in &stats.failed_ref_names {
251 write!(formatter, " ")?;
252 write!(formatter.labeled("git_ref"), "{name}")?;
253 writeln!(formatter)?;
254 }
255 }
256 if stats
257 .failed_ref_names
258 .iter()
259 .any(|name| name.starts_with(git::RESERVED_REMOTE_REF_NAMESPACE.as_bytes()))
260 {
261 writedoc!(
262 ui.hint_default(),
263 "
264 Git remote named '{name}' is reserved for local Git repository.
265 Use `jj git remote rename` to give a different name.
266 ",
267 name = git::REMOTE_NAME_FOR_LOCAL_GIT_REPO.as_symbol(),
268 )?;
269 }
270
271 Ok(())
272}
273
274pub struct Progress {
275 next_print: Instant,
276 rate: RateEstimate,
277 buffer: String,
278 guard: Option<CleanupGuard>,
279}
280
281impl Progress {
282 pub fn new(now: Instant) -> Self {
283 Self {
284 next_print: now + crate::progress::INITIAL_DELAY,
285 rate: RateEstimate::new(),
286 buffer: String::new(),
287 guard: None,
288 }
289 }
290
291 pub fn update<W: std::io::Write>(
292 &mut self,
293 now: Instant,
294 progress: &git::Progress,
295 output: &mut ProgressOutput<W>,
296 ) -> io::Result<()> {
297 use std::fmt::Write as _;
298
299 if progress.overall == 1.0 {
300 write!(output, "\r{}", Clear(ClearType::CurrentLine))?;
301 output.flush()?;
302 return Ok(());
303 }
304
305 let rate = progress
306 .bytes_downloaded
307 .and_then(|x| self.rate.update(now, x));
308 if now < self.next_print {
309 return Ok(());
310 }
311 self.next_print = now + Duration::from_secs(1) / crate::progress::UPDATE_HZ;
312 if self.guard.is_none() {
313 let guard = output.output_guard(crossterm::cursor::Show.to_string());
314 let guard = CleanupGuard::new(move || {
315 drop(guard);
316 });
317 write!(output, "{}", crossterm::cursor::Hide).ok();
318 self.guard = Some(guard);
319 }
320
321 self.buffer.clear();
322 self.buffer.push('\r');
324 let control_chars = self.buffer.len();
325 write!(self.buffer, "{: >3.0}% ", 100.0 * progress.overall).unwrap();
326 if let Some(total) = progress.bytes_downloaded {
327 let (scaled, prefix) = binary_prefix(total as f32);
328 write!(self.buffer, "{scaled: >5.1} {prefix}B ").unwrap();
329 }
330 if let Some(estimate) = rate {
331 let (scaled, prefix) = binary_prefix(estimate);
332 write!(self.buffer, "at {scaled: >5.1} {prefix}B/s ").unwrap();
333 }
334
335 let bar_width = output
336 .term_width()
337 .map(usize::from)
338 .unwrap_or(0)
339 .saturating_sub(self.buffer.len() - control_chars + 2);
340 self.buffer.push('[');
341 draw_progress(progress.overall, &mut self.buffer, bar_width);
342 self.buffer.push(']');
343
344 write!(self.buffer, "{}", Clear(ClearType::UntilNewLine)).unwrap();
345 self.buffer.push('\r');
348 write!(output, "{}", self.buffer)?;
349 output.flush()?;
350 Ok(())
351 }
352}
353
354fn draw_progress(progress: f32, buffer: &mut String, width: usize) {
355 const CHARS: [char; 9] = [' ', '▏', '▎', '▍', '▌', '▋', '▊', '▉', '█'];
356 const RESOLUTION: usize = CHARS.len() - 1;
357 let ticks = (width as f32 * progress.clamp(0.0, 1.0) * RESOLUTION as f32).round() as usize;
358 let whole = ticks / RESOLUTION;
359 for _ in 0..whole {
360 buffer.push(CHARS[CHARS.len() - 1]);
361 }
362 if whole < width {
363 let fraction = ticks % RESOLUTION;
364 buffer.push(CHARS[fraction]);
365 }
366 for _ in (whole + 1)..width {
367 buffer.push(CHARS[0]);
368 }
369}
370
371struct RateEstimate {
372 state: Option<RateEstimateState>,
373}
374
375impl RateEstimate {
376 pub fn new() -> Self {
377 Self { state: None }
378 }
379
380 pub fn update(&mut self, now: Instant, total: u64) -> Option<f32> {
382 if let Some(ref mut state) = self.state {
383 return Some(state.update(now, total));
384 }
385
386 self.state = Some(RateEstimateState {
387 total,
388 avg_rate: None,
389 last_sample: now,
390 });
391 None
392 }
393}
394
395struct RateEstimateState {
396 total: u64,
397 avg_rate: Option<f32>,
398 last_sample: Instant,
399}
400
401impl RateEstimateState {
402 fn update(&mut self, now: Instant, total: u64) -> f32 {
403 let delta = total - self.total;
404 self.total = total;
405 let dt = now - self.last_sample;
406 self.last_sample = now;
407 let sample = delta as f32 / dt.as_secs_f32();
408 match self.avg_rate {
409 None => *self.avg_rate.insert(sample),
410 Some(ref mut avg_rate) => {
411 const TIME_WINDOW: f32 = 2.0;
414 let alpha = 1.0 - (-dt.as_secs_f32() / TIME_WINDOW).exp();
415 *avg_rate += alpha * (sample - *avg_rate);
416 *avg_rate
417 }
418 }
419 }
420}
421
422struct RefStatus {
423 ref_kind: GitRefKind,
424 symbol: String,
425 tracking_status: TrackingStatus,
426 import_status: ImportStatus,
427}
428
429impl RefStatus {
430 fn new(
431 ref_kind: GitRefKind,
432 symbol: RemoteRefSymbol<'_>,
433 remote_ref: &RemoteRef,
434 ref_target: &RefTarget,
435 repo: &dyn Repo,
436 ) -> Self {
437 let tracking_status = match ref_kind {
438 GitRefKind::Bookmark => {
439 if repo.view().get_remote_bookmark(symbol).is_tracked() {
440 TrackingStatus::Tracked
441 } else {
442 TrackingStatus::Untracked
443 }
444 }
445 GitRefKind::Tag => TrackingStatus::NotApplicable,
446 };
447
448 let import_status = match (remote_ref.target.is_absent(), ref_target.is_absent()) {
449 (true, false) => ImportStatus::New,
450 (false, true) => ImportStatus::Deleted,
451 _ => ImportStatus::Updated,
452 };
453
454 Self {
455 symbol: symbol.to_string(),
456 tracking_status,
457 import_status,
458 ref_kind,
459 }
460 }
461
462 fn output(&self, max_symbol_width: usize, out: &mut dyn Formatter) -> std::io::Result<()> {
463 let tracking_status = match self.tracking_status {
464 TrackingStatus::Tracked => "tracked",
465 TrackingStatus::Untracked => "untracked",
466 TrackingStatus::NotApplicable => "",
467 };
468
469 let import_status = match self.import_status {
470 ImportStatus::New => "new",
471 ImportStatus::Deleted => "deleted",
472 ImportStatus::Updated => "updated",
473 };
474
475 let symbol_width = self.symbol.width();
476 let pad_width = max_symbol_width.saturating_sub(symbol_width);
477 let padded_symbol = format!("{}{:>pad_width$}", self.symbol, "", pad_width = pad_width);
478
479 let label = match self.ref_kind {
480 GitRefKind::Bookmark => "bookmark",
481 GitRefKind::Tag => "tag",
482 };
483
484 write!(out, "{label}: ")?;
485 write!(out.labeled(label), "{padded_symbol}")?;
486 writeln!(out, " [{import_status}] {tracking_status}")
487 }
488}
489
490enum TrackingStatus {
491 Tracked,
492 Untracked,
493 NotApplicable, }
495
496enum ImportStatus {
497 New,
498 Deleted,
499 Updated,
500}
501
502pub fn print_git_export_stats(ui: &Ui, stats: &GitExportStats) -> Result<(), std::io::Error> {
503 if !stats.failed_bookmarks.is_empty() {
504 writeln!(ui.warning_default(), "Failed to export some bookmarks:")?;
505 let mut formatter = ui.stderr_formatter();
506 for (symbol, reason) in &stats.failed_bookmarks {
507 write!(formatter, " ")?;
508 write!(formatter.labeled("bookmark"), "{symbol}")?;
509 for err in iter::successors(Some(reason as &dyn error::Error), |err| err.source()) {
510 write!(formatter, ": {err}")?;
511 }
512 writeln!(formatter)?;
513 }
514 }
515 if !stats.failed_tags.is_empty() {
516 writeln!(ui.warning_default(), "Failed to export some tags:")?;
517 let mut formatter = ui.stderr_formatter();
518 for (symbol, reason) in &stats.failed_tags {
519 write!(formatter, " ")?;
520 write!(formatter.labeled("tag"), "{symbol}")?;
521 for err in iter::successors(Some(reason as &dyn error::Error), |err| err.source()) {
522 write!(formatter, ": {err}")?;
523 }
524 writeln!(formatter)?;
525 }
526 }
527 if itertools::chain(&stats.failed_bookmarks, &stats.failed_tags)
528 .any(|(_, reason)| matches!(reason, FailedRefExportReason::FailedToSet(_)))
529 {
530 writedoc!(
531 ui.hint_default(),
532 r#"
533 Git doesn't allow a branch/tag name that looks like a parent directory of
534 another (e.g. `foo` and `foo/bar`). Try to rename the bookmarks/tags that failed
535 to export or their "parent" bookmarks/tags.
536 "#,
537 )?;
538 }
539 Ok(())
540}
541
542#[cfg(test)]
543mod tests {
544 use std::path::MAIN_SEPARATOR;
545
546 use insta::assert_snapshot;
547
548 use super::*;
549
550 #[test]
551 fn test_absolute_git_url() {
552 let temp_dir = testutils::new_temp_dir();
555 let cwd = dunce::canonicalize(temp_dir.path()).unwrap();
556 let cwd_slash = cwd.to_str().unwrap().replace(MAIN_SEPARATOR, "/");
557
558 assert_eq!(
560 absolute_git_url(&cwd, "foo").unwrap(),
561 format!("{cwd_slash}/foo")
562 );
563 assert_eq!(
564 absolute_git_url(&cwd, r"foo\bar").unwrap(),
565 if cfg!(windows) {
566 format!("{cwd_slash}/foo/bar")
567 } else {
568 format!(r"{cwd_slash}/foo\bar")
569 }
570 );
571 assert_eq!(
572 absolute_git_url(&cwd.join("bar"), &format!("{cwd_slash}/foo")).unwrap(),
573 format!("{cwd_slash}/foo")
574 );
575
576 assert_eq!(
578 absolute_git_url(&cwd, "git@example.org:foo/bar.git").unwrap(),
579 "git@example.org:foo/bar.git"
580 );
581 assert_eq!(
583 absolute_git_url(&cwd, "https://example.org/foo.git").unwrap(),
584 "https://example.org/foo.git"
585 );
586 assert_eq!(
588 absolute_git_url(&cwd, "custom://example.org/foo.git").unwrap(),
589 "custom://example.org/foo.git"
590 );
591 assert_eq!(
593 absolute_git_url(&cwd, "https://user:pass@example.org/").unwrap(),
594 "https://user:pass@example.org/"
595 );
596 }
597
598 #[test]
599 fn test_bar() {
600 let mut buf = String::new();
601 draw_progress(0.0, &mut buf, 10);
602 assert_eq!(buf, " ");
603 buf.clear();
604 draw_progress(1.0, &mut buf, 10);
605 assert_eq!(buf, "██████████");
606 buf.clear();
607 draw_progress(0.5, &mut buf, 10);
608 assert_eq!(buf, "█████ ");
609 buf.clear();
610 draw_progress(0.54, &mut buf, 10);
611 assert_eq!(buf, "█████▍ ");
612 buf.clear();
613 }
614
615 #[test]
616 fn test_update() {
617 let start = Instant::now();
618 let mut progress = Progress::new(start);
619 let mut current_time = start;
620 let mut update = |duration, overall| -> String {
621 current_time += duration;
622 let mut buf = vec![];
623 let mut output = ProgressOutput::for_test(&mut buf, 25);
624 progress
625 .update(
626 current_time,
627 &jj_lib::git::Progress {
628 bytes_downloaded: None,
629 overall,
630 },
631 &mut output,
632 )
633 .unwrap();
634 String::from_utf8(buf).unwrap()
635 };
636 assert_snapshot!(update(crate::progress::INITIAL_DELAY - Duration::from_millis(1), 0.1), @"");
638 assert_snapshot!(update(Duration::from_millis(1), 0.10), @"\u{1b}[?25l\r 10% [█▊ ]\u{1b}[K");
639 assert_snapshot!(update(Duration::from_millis(10), 0.11), @"");
641 assert_snapshot!(update(Duration::from_millis(10), 0.12), @"");
642 assert_snapshot!(update(Duration::from_millis(10), 0.13), @"");
643 assert_snapshot!(update(Duration::from_millis(100), 0.30), @"\r 30% [█████▍ ]\u{1b}[K");
645 assert_snapshot!(update(Duration::from_millis(30), 0.40), @"");
648 }
649}