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 bstr::ByteSlice as _;
27use crossterm::terminal::Clear;
28use crossterm::terminal::ClearType;
29use indoc::writedoc;
30use itertools::Itertools as _;
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::GitProgress;
37use jj_lib::git::GitPushStats;
38use jj_lib::git::GitRefKind;
39use jj_lib::git::GitSettings;
40use jj_lib::git::GitSidebandLineTerminator;
41use jj_lib::git::GitSubprocessCallback;
42use jj_lib::op_store::RefTarget;
43use jj_lib::op_store::RemoteRef;
44use jj_lib::ref_name::RemoteRefSymbol;
45use jj_lib::repo::ReadonlyRepo;
46use jj_lib::repo::Repo;
47use jj_lib::settings::RemoteSettingsMap;
48use jj_lib::workspace::Workspace;
49use unicode_width::UnicodeWidthStr as _;
50
51use crate::cleanup_guard::CleanupGuard;
52use crate::cli_util::WorkspaceCommandTransaction;
53use crate::cli_util::print_updated_commits;
54use crate::command_error::CommandError;
55use crate::command_error::cli_error;
56use crate::command_error::user_error;
57use crate::formatter::Formatter;
58use crate::formatter::FormatterExt as _;
59use crate::revset_util::parse_remote_auto_track_bookmarks_map;
60use crate::ui::ProgressOutput;
61use crate::ui::Ui;
62
63pub fn is_colocated_git_workspace(workspace: &Workspace, repo: &ReadonlyRepo) -> bool {
64 let Ok(git_backend) = git::get_git_backend(repo.store()) else {
65 return false;
66 };
67 let Some(git_workdir) = git_backend.git_workdir() else {
68 return false; };
70 if git_workdir == workspace.workspace_root() {
71 return true;
72 }
73 let Ok(dot_git_path) = dunce::canonicalize(workspace.workspace_root().join(".git")) else {
76 return false;
77 };
78 dunce::canonicalize(git_workdir).ok().as_deref() == dot_git_path.parent()
79}
80
81pub fn absolute_git_url(cwd: &Path, source: &str) -> Result<String, CommandError> {
83 let mut url = gix::url::parse(source.as_ref()).map_err(cli_error)?;
88 url.canonicalize(cwd).map_err(user_error)?;
89 if url.scheme == gix::url::Scheme::File {
92 url.path = gix::path::to_unix_separators_on_windows(mem::take(&mut url.path)).into_owned();
93 }
94 Ok(String::from_utf8(url.to_bstring().into()).unwrap_or_else(|_| source.to_owned()))
96}
97
98fn git_remote_url_to_web(url: &gix::Url) -> Option<String> {
102 if url.scheme == gix::url::Scheme::File || url.host().is_none() {
103 return None;
104 }
105
106 let host = url.host()?;
107 let path = url.path.to_str().ok()?;
108 let path = path.trim_matches('/');
109 let path = path.strip_suffix(".git").unwrap_or(path);
110
111 Some(format!("https://{host}/{path}"))
112}
113
114pub fn get_remote_web_url(repo: &ReadonlyRepo, remote_name: &str) -> Option<String> {
119 let git_repo = git::get_git_repo(repo.store()).ok()?;
120 let remote = git_repo.try_find_remote(remote_name)?.ok()?;
121 let url = remote
122 .url(gix::remote::Direction::Fetch)
123 .or_else(|| remote.url(gix::remote::Direction::Push))?;
124 git_remote_url_to_web(url)
125}
126
127pub struct GitSubprocessUi<'a> {
129 ui: &'a Ui,
132 progress_output: Option<ProgressOutput<io::Stderr>>,
133 progress: Progress,
134 erase_end: &'static [u8],
136}
137
138impl<'a> GitSubprocessUi<'a> {
139 pub fn new(ui: &'a Ui) -> Self {
140 let progress_output = ui.progress_output();
141 let is_terminal = progress_output.is_some();
142 Self {
143 ui,
144 progress_output,
145 progress: Progress::new(Instant::now()),
146 erase_end: if is_terminal { b"\x1B[K" } else { b" " },
147 }
148 }
149
150 fn write_sideband(
151 &self,
152 prefix: &[u8],
153 message: &[u8],
154 term: Option<GitSidebandLineTerminator>,
155 ) -> io::Result<()> {
156 let mut scratch =
159 Vec::with_capacity(prefix.len() + message.len() + self.erase_end.len() + 1);
160 scratch.extend_from_slice(prefix);
161 scratch.extend_from_slice(message);
162 if !message.is_empty() {
168 scratch.extend_from_slice(self.erase_end);
169 }
170 scratch.push(term.map_or(b'\n', |t| t.as_byte()));
172 self.ui.status().write_all(&scratch)
173 }
174}
175
176impl GitSubprocessCallback for GitSubprocessUi<'_> {
177 fn needs_progress(&self) -> bool {
178 self.progress_output.is_some()
179 }
180
181 fn progress(&mut self, progress: &GitProgress) -> io::Result<()> {
182 if let Some(output) = &mut self.progress_output {
183 self.progress.update(Instant::now(), progress, output)
184 } else {
185 Ok(())
186 }
187 }
188
189 fn local_sideband(
190 &mut self,
191 message: &[u8],
192 term: Option<GitSidebandLineTerminator>,
193 ) -> io::Result<()> {
194 self.write_sideband(b"git: ", message, term)
195 }
196
197 fn remote_sideband(
198 &mut self,
199 message: &[u8],
200 term: Option<GitSidebandLineTerminator>,
201 ) -> io::Result<()> {
202 self.write_sideband(b"remote: ", message, term)
203 }
204}
205
206pub fn load_git_import_options(
207 ui: &Ui,
208 git_settings: &GitSettings,
209 remote_settings: &RemoteSettingsMap,
210) -> Result<GitImportOptions, CommandError> {
211 Ok(GitImportOptions {
212 abandon_unreachable_commits: git_settings.abandon_unreachable_commits,
213 record_synthetic_predecessors: git_settings.record_synthetic_predecessors,
214 remote_auto_track_bookmarks: parse_remote_auto_track_bookmarks_map(ui, remote_settings)?,
215 })
216}
217
218pub fn print_git_import_stats(
219 ui: &Ui,
220 tx: &WorkspaceCommandTransaction<'_>,
221 stats: &GitImportStats,
222) -> Result<(), CommandError> {
223 if let Some(mut formatter) = ui.status_formatter() {
224 print_imported_changes(formatter.as_mut(), tx, stats)?;
225 }
226 print_failed_git_import(ui, stats)?;
227 Ok(())
228}
229
230fn print_imported_changes(
231 formatter: &mut dyn Formatter,
232 tx: &WorkspaceCommandTransaction<'_>,
233 stats: &GitImportStats,
234) -> Result<(), CommandError> {
235 for (kind, changes) in [
236 (GitRefKind::Bookmark, &stats.changed_remote_bookmarks),
237 (GitRefKind::Tag, &stats.changed_remote_tags),
238 ] {
239 let refs_stats = changes
240 .iter()
241 .map(|(symbol, (remote_ref, ref_target))| {
242 RefStatus::new(kind, symbol.as_ref(), remote_ref, ref_target, tx.repo())
243 })
244 .collect_vec();
245 let Some(max_width) = refs_stats.iter().map(|x| x.symbol.width()).max() else {
246 continue;
247 };
248 for status in refs_stats {
249 status.output(max_width, formatter)?;
250 }
251 }
252
253 if !stats.abandoned_commits.is_empty() {
254 writeln!(
255 formatter,
256 "Abandoned {} commits that are no longer reachable:",
257 stats.abandoned_commits.len()
258 )?;
259 let template = tx.commit_summary_template();
260 print_updated_commits(formatter, &template, &stats.abandoned_commits)?;
261 }
262 if !stats.rewritten_commit_ids.is_empty() {
263 writeln!(
264 formatter,
265 "Updated {} rewritten commits.",
266 stats.rewritten_commit_ids.len()
267 )?;
268 }
269
270 Ok(())
271}
272
273fn print_failed_git_import(ui: &Ui, stats: &GitImportStats) -> Result<(), CommandError> {
274 if !stats.failed_ref_names.is_empty() {
275 writeln!(ui.warning_default(), "Failed to import some Git refs:")?;
276 let mut formatter = ui.stderr_formatter();
277 for name in &stats.failed_ref_names {
278 write!(formatter, " ")?;
279 write!(formatter.labeled("git_ref"), "{name}")?;
280 writeln!(formatter)?;
281 }
282 }
283 if stats
284 .failed_ref_names
285 .iter()
286 .any(|name| name.starts_with(git::RESERVED_REMOTE_REF_NAMESPACE.as_bytes()))
287 {
288 writedoc!(
289 ui.hint_default(),
290 "
291 Git remote named '{name}' is reserved for local Git repository.
292 Use `jj git remote rename` to give a different name.
293 ",
294 name = git::REMOTE_NAME_FOR_LOCAL_GIT_REPO.as_symbol(),
295 )?;
296 }
297 Ok(())
298}
299
300pub fn print_git_import_stats_summary(ui: &Ui, stats: &GitImportStats) -> Result<(), CommandError> {
303 if let Some(mut formatter) = ui.status_formatter() {
304 if !stats.abandoned_commits.is_empty() {
305 writeln!(
306 formatter,
307 "Abandoned {} commits that are no longer reachable.",
308 stats.abandoned_commits.len()
309 )?;
310 }
311 if !stats.rewritten_commit_ids.is_empty() {
312 writeln!(
313 formatter,
314 "Updated {} rewritten commits.",
315 stats.rewritten_commit_ids.len()
316 )?;
317 }
318 }
319 print_failed_git_import(ui, stats)?;
320 Ok(())
321}
322
323pub struct Progress {
324 next_print: Instant,
325 buffer: String,
326 guard: Option<CleanupGuard>,
327}
328
329impl Progress {
330 pub fn new(now: Instant) -> Self {
331 Self {
332 next_print: now + crate::progress::INITIAL_DELAY,
333 buffer: String::new(),
334 guard: None,
335 }
336 }
337
338 pub fn update<W: std::io::Write>(
339 &mut self,
340 now: Instant,
341 progress: &GitProgress,
342 output: &mut ProgressOutput<W>,
343 ) -> io::Result<()> {
344 use std::fmt::Write as _;
345
346 if progress.overall() == 1.0 {
347 write!(output, "\r{}", Clear(ClearType::CurrentLine))?;
348 output.flush()?;
349 return Ok(());
350 }
351
352 if now < self.next_print {
353 return Ok(());
354 }
355 self.next_print = now + Duration::from_secs(1) / crate::progress::UPDATE_HZ;
356 if self.guard.is_none() {
357 let guard = output.output_guard(crossterm::cursor::Show.to_string());
358 let guard = CleanupGuard::new(move || {
359 drop(guard);
360 });
361 write!(output, "{}", crossterm::cursor::Hide).ok();
362 self.guard = Some(guard);
363 }
364
365 self.buffer.clear();
366 self.buffer.push('\r');
368 let control_chars = self.buffer.len();
369 write!(self.buffer, "{: >3.0}% ", 100.0 * progress.overall()).unwrap();
370
371 let bar_width = output
372 .term_width()
373 .map(usize::from)
374 .unwrap_or(0)
375 .saturating_sub(self.buffer.len() - control_chars + 2);
376 self.buffer.push('[');
377 draw_progress(progress.overall(), &mut self.buffer, bar_width);
378 self.buffer.push(']');
379
380 write!(self.buffer, "{}", Clear(ClearType::UntilNewLine)).unwrap();
381 self.buffer.push('\r');
384 write!(output, "{}", self.buffer)?;
385 output.flush()?;
386 Ok(())
387 }
388}
389
390fn draw_progress(progress: f32, buffer: &mut String, width: usize) {
391 const CHARS: [char; 9] = [' ', '▏', '▎', '▍', '▌', '▋', '▊', '▉', '█'];
392 const RESOLUTION: usize = CHARS.len() - 1;
393 let ticks = (width as f32 * progress.clamp(0.0, 1.0) * RESOLUTION as f32).round() as usize;
394 let whole = ticks / RESOLUTION;
395 for _ in 0..whole {
396 buffer.push(CHARS[CHARS.len() - 1]);
397 }
398 if whole < width {
399 let fraction = ticks % RESOLUTION;
400 buffer.push(CHARS[fraction]);
401 }
402 for _ in (whole + 1)..width {
403 buffer.push(CHARS[0]);
404 }
405}
406
407struct RefStatus {
408 ref_kind: GitRefKind,
409 symbol: String,
410 tracking_status: TrackingStatus,
411 import_status: ImportStatus,
412}
413
414impl RefStatus {
415 fn new(
416 ref_kind: GitRefKind,
417 symbol: RemoteRefSymbol<'_>,
418 remote_ref: &RemoteRef,
419 ref_target: &RefTarget,
420 repo: &dyn Repo,
421 ) -> Self {
422 let tracking_status = match ref_kind {
423 GitRefKind::Bookmark => {
424 if repo.view().get_remote_bookmark(symbol).is_tracked() {
425 TrackingStatus::Tracked
426 } else {
427 TrackingStatus::Untracked
428 }
429 }
430 GitRefKind::Tag => TrackingStatus::NotApplicable,
431 };
432
433 let import_status = match (remote_ref.target.is_absent(), ref_target.is_absent()) {
434 (true, false) => ImportStatus::New,
435 (false, true) => ImportStatus::Deleted,
436 _ => ImportStatus::Updated,
437 };
438
439 Self {
440 symbol: symbol.to_string(),
441 tracking_status,
442 import_status,
443 ref_kind,
444 }
445 }
446
447 fn output(&self, max_symbol_width: usize, out: &mut dyn Formatter) -> std::io::Result<()> {
448 let tracking_status = match self.tracking_status {
449 TrackingStatus::Tracked => "tracked",
450 TrackingStatus::Untracked => "untracked",
451 TrackingStatus::NotApplicable => "",
452 };
453
454 let import_status = match self.import_status {
455 ImportStatus::New => "new",
456 ImportStatus::Deleted => "deleted",
457 ImportStatus::Updated => "updated",
458 };
459
460 let symbol_width = self.symbol.width();
461 let pad_width = max_symbol_width.saturating_sub(symbol_width);
462 let padded_symbol = format!("{}{:>pad_width$}", self.symbol, "", pad_width = pad_width);
463
464 let label = match self.ref_kind {
465 GitRefKind::Bookmark => "bookmark",
466 GitRefKind::Tag => "tag",
467 };
468
469 write!(out, "{label}: ")?;
470 write!(out.labeled(label), "{padded_symbol}")?;
471 writeln!(out, " [{import_status}] {tracking_status}")
472 }
473}
474
475enum TrackingStatus {
476 Tracked,
477 Untracked,
478 NotApplicable, }
480
481enum ImportStatus {
482 New,
483 Deleted,
484 Updated,
485}
486
487pub fn print_git_export_stats(ui: &Ui, stats: &GitExportStats) -> Result<(), std::io::Error> {
488 if !stats.failed_bookmarks.is_empty() {
489 writeln!(ui.warning_default(), "Failed to export some bookmarks:")?;
490 let mut formatter = ui.stderr_formatter();
491 for (symbol, reason) in &stats.failed_bookmarks {
492 write!(formatter, " ")?;
493 write!(formatter.labeled("bookmark"), "{symbol}")?;
494 for err in iter::successors(Some(reason as &dyn error::Error), |err| err.source()) {
495 write!(formatter, ": {err}")?;
496 }
497 writeln!(formatter)?;
498 }
499 }
500 if !stats.failed_tags.is_empty() {
501 writeln!(ui.warning_default(), "Failed to export some tags:")?;
502 let mut formatter = ui.stderr_formatter();
503 for (symbol, reason) in &stats.failed_tags {
504 write!(formatter, " ")?;
505 write!(formatter.labeled("tag"), "{symbol}")?;
506 for err in iter::successors(Some(reason as &dyn error::Error), |err| err.source()) {
507 write!(formatter, ": {err}")?;
508 }
509 writeln!(formatter)?;
510 }
511 }
512 if itertools::chain(&stats.failed_bookmarks, &stats.failed_tags)
513 .any(|(_, reason)| matches!(reason, FailedRefExportReason::FailedToSet(_)))
514 {
515 writedoc!(
516 ui.hint_default(),
517 r#"
518 Git doesn't allow a branch/tag name that looks like a parent directory of
519 another (e.g. `foo` and `foo/bar`). Try to rename the bookmarks/tags that failed
520 to export or their "parent" bookmarks/tags.
521 "#,
522 )?;
523 }
524 Ok(())
525}
526
527pub fn print_push_stats(ui: &Ui, stats: &GitPushStats) -> io::Result<()> {
528 if !stats.rejected.is_empty() {
529 writeln!(
530 ui.warning_default(),
531 "The following references unexpectedly moved on the remote:"
532 )?;
533 let mut formatter = ui.stderr_formatter();
534 for (reference, reason) in &stats.rejected {
535 write!(formatter, " ")?;
536 write!(formatter.labeled("git_ref"), "{}", reference.as_symbol())?;
537 if let Some(r) = reason {
538 write!(formatter, " (reason: {r})")?;
539 }
540 writeln!(formatter)?;
541 }
542 drop(formatter);
543 writeln!(
544 ui.hint_default(),
545 "Try fetching from the remote, then make the bookmark point to where you want it to \
546 be, and push again.",
547 )?;
548 }
549 if !stats.remote_rejected.is_empty() {
550 writeln!(
551 ui.warning_default(),
552 "The remote rejected the following updates:"
553 )?;
554 let mut formatter = ui.stderr_formatter();
555 for (reference, reason) in &stats.remote_rejected {
556 write!(formatter, " ")?;
557 write!(formatter.labeled("git_ref"), "{}", reference.as_symbol())?;
558 if let Some(r) = reason {
559 write!(formatter, " (reason: {r})")?;
560 }
561 writeln!(formatter)?;
562 }
563 drop(formatter);
564 writeln!(
565 ui.hint_default(),
566 "Try checking if you have permission to push to all the bookmarks."
567 )?;
568 }
569 if !stats.unexported_bookmarks.is_empty() {
570 writeln!(
571 ui.warning_default(),
572 "The following bookmarks couldn't be updated locally:"
573 )?;
574 let mut formatter = ui.stderr_formatter();
575 for (symbol, reason) in &stats.unexported_bookmarks {
576 write!(formatter, " ")?;
577 write!(formatter.labeled("bookmark"), "{symbol}")?;
578 for err in iter::successors(Some(reason as &dyn error::Error), |err| err.source()) {
579 write!(formatter, ": {err}")?;
580 }
581 writeln!(formatter)?;
582 }
583 }
584 Ok(())
585}
586
587#[cfg(test)]
588mod tests {
589 use std::path::MAIN_SEPARATOR;
590
591 use insta::assert_snapshot;
592
593 use super::*;
594
595 #[test]
596 fn test_absolute_git_url() {
597 let temp_dir = testutils::new_temp_dir();
600 let cwd = dunce::canonicalize(temp_dir.path()).unwrap();
601 let cwd_slash = cwd.to_str().unwrap().replace(MAIN_SEPARATOR, "/");
602
603 assert_eq!(
605 absolute_git_url(&cwd, "foo").unwrap(),
606 format!("{cwd_slash}/foo")
607 );
608 assert_eq!(
609 absolute_git_url(&cwd, r"foo\bar").unwrap(),
610 if cfg!(windows) {
611 format!("{cwd_slash}/foo/bar")
612 } else {
613 format!(r"{cwd_slash}/foo\bar")
614 }
615 );
616 assert_eq!(
617 absolute_git_url(&cwd.join("bar"), &format!("{cwd_slash}/foo")).unwrap(),
618 format!("{cwd_slash}/foo")
619 );
620
621 assert_eq!(
623 absolute_git_url(&cwd, "git@example.org:foo/bar.git").unwrap(),
624 "git@example.org:foo/bar.git"
625 );
626 assert_eq!(
628 absolute_git_url(&cwd, "https://example.org/foo.git").unwrap(),
629 "https://example.org/foo.git"
630 );
631 assert_eq!(
633 absolute_git_url(&cwd, "custom://example.org/foo.git").unwrap(),
634 "custom://example.org/foo.git"
635 );
636 assert_eq!(
638 absolute_git_url(&cwd, "https://user:pass@example.org/").unwrap(),
639 "https://user:pass@example.org/"
640 );
641
642 assert_eq!(
644 absolute_git_url(&cwd, "https://example.org/%20%25").unwrap(),
645 "https://example.org/%20%25"
646 );
647 assert!(
649 absolute_git_url(&cwd, "file:///%20%25")
650 .unwrap()
651 .ends_with("/%20%25")
652 );
653 }
654
655 #[test]
656 fn test_git_remote_url_to_web() {
657 let to_web = |s| git_remote_url_to_web(&gix::Url::try_from(s).unwrap());
658
659 assert_eq!(
661 to_web("git@github.com:owner/repo"),
662 Some("https://github.com/owner/repo".to_owned())
663 );
664 assert_eq!(
666 to_web("https://github.com/owner/repo.git"),
667 Some("https://github.com/owner/repo".to_owned())
668 );
669 assert_eq!(
671 to_web("ssh://git@github.com/owner/repo"),
672 Some("https://github.com/owner/repo".to_owned())
673 );
674 assert_eq!(
676 to_web("git://github.com/owner/repo.git"),
677 Some("https://github.com/owner/repo".to_owned())
678 );
679 assert_eq!(to_web("file:///path/to/repo"), None);
681 assert_eq!(to_web("/path/to/repo"), None);
683 }
684
685 #[test]
686 fn test_bar() {
687 let mut buf = String::new();
688 draw_progress(0.0, &mut buf, 10);
689 assert_eq!(buf, " ");
690 buf.clear();
691 draw_progress(1.0, &mut buf, 10);
692 assert_eq!(buf, "██████████");
693 buf.clear();
694 draw_progress(0.5, &mut buf, 10);
695 assert_eq!(buf, "█████ ");
696 buf.clear();
697 draw_progress(0.54, &mut buf, 10);
698 assert_eq!(buf, "█████▍ ");
699 buf.clear();
700 }
701
702 #[test]
703 fn test_update() {
704 let start = Instant::now();
705 let mut progress = Progress::new(start);
706 let mut current_time = start;
707 let mut update = |duration, overall: u64| -> String {
708 current_time += duration;
709 let mut buf = vec![];
710 let mut output = ProgressOutput::for_test(&mut buf, 25);
711 progress
712 .update(
713 current_time,
714 &GitProgress {
715 deltas: (overall, 100),
716 objects: (0, 0),
717 counted_objects: (0, 0),
718 compressed_objects: (0, 0),
719 },
720 &mut output,
721 )
722 .unwrap();
723 String::from_utf8(buf).unwrap()
724 };
725 assert_snapshot!(update(crate::progress::INITIAL_DELAY - Duration::from_millis(1), 1), @"");
727 assert_snapshot!(update(Duration::from_millis(1), 10), @"\u{1b}[?25l\r 10% [█▊ ]\u{1b}[K");
728 assert_snapshot!(update(Duration::from_millis(10), 11), @"");
730 assert_snapshot!(update(Duration::from_millis(10), 12), @"");
731 assert_snapshot!(update(Duration::from_millis(10), 13), @"");
732 assert_snapshot!(update(Duration::from_millis(100), 30), @"\r 30% [█████▍ ]\u{1b}[K");
734 assert_snapshot!(update(Duration::from_millis(30), 40), @"");
737 }
738}