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
263 Ok(())
264}
265
266fn print_failed_git_import(ui: &Ui, stats: &GitImportStats) -> Result<(), CommandError> {
267 if !stats.failed_ref_names.is_empty() {
268 writeln!(ui.warning_default(), "Failed to import some Git refs:")?;
269 let mut formatter = ui.stderr_formatter();
270 for name in &stats.failed_ref_names {
271 write!(formatter, " ")?;
272 write!(formatter.labeled("git_ref"), "{name}")?;
273 writeln!(formatter)?;
274 }
275 }
276 if stats
277 .failed_ref_names
278 .iter()
279 .any(|name| name.starts_with(git::RESERVED_REMOTE_REF_NAMESPACE.as_bytes()))
280 {
281 writedoc!(
282 ui.hint_default(),
283 "
284 Git remote named '{name}' is reserved for local Git repository.
285 Use `jj git remote rename` to give a different name.
286 ",
287 name = git::REMOTE_NAME_FOR_LOCAL_GIT_REPO.as_symbol(),
288 )?;
289 }
290 Ok(())
291}
292
293pub fn print_git_import_stats_summary(ui: &Ui, stats: &GitImportStats) -> Result<(), CommandError> {
296 if !stats.abandoned_commits.is_empty()
297 && let Some(mut formatter) = ui.status_formatter()
298 {
299 writeln!(
300 formatter,
301 "Abandoned {} commits that are no longer reachable.",
302 stats.abandoned_commits.len()
303 )?;
304 }
305 print_failed_git_import(ui, stats)?;
306 Ok(())
307}
308
309pub struct Progress {
310 next_print: Instant,
311 buffer: String,
312 guard: Option<CleanupGuard>,
313}
314
315impl Progress {
316 pub fn new(now: Instant) -> Self {
317 Self {
318 next_print: now + crate::progress::INITIAL_DELAY,
319 buffer: String::new(),
320 guard: None,
321 }
322 }
323
324 pub fn update<W: std::io::Write>(
325 &mut self,
326 now: Instant,
327 progress: &GitProgress,
328 output: &mut ProgressOutput<W>,
329 ) -> io::Result<()> {
330 use std::fmt::Write as _;
331
332 if progress.overall() == 1.0 {
333 write!(output, "\r{}", Clear(ClearType::CurrentLine))?;
334 output.flush()?;
335 return Ok(());
336 }
337
338 if now < self.next_print {
339 return Ok(());
340 }
341 self.next_print = now + Duration::from_secs(1) / crate::progress::UPDATE_HZ;
342 if self.guard.is_none() {
343 let guard = output.output_guard(crossterm::cursor::Show.to_string());
344 let guard = CleanupGuard::new(move || {
345 drop(guard);
346 });
347 write!(output, "{}", crossterm::cursor::Hide).ok();
348 self.guard = Some(guard);
349 }
350
351 self.buffer.clear();
352 self.buffer.push('\r');
354 let control_chars = self.buffer.len();
355 write!(self.buffer, "{: >3.0}% ", 100.0 * progress.overall()).unwrap();
356
357 let bar_width = output
358 .term_width()
359 .map(usize::from)
360 .unwrap_or(0)
361 .saturating_sub(self.buffer.len() - control_chars + 2);
362 self.buffer.push('[');
363 draw_progress(progress.overall(), &mut self.buffer, bar_width);
364 self.buffer.push(']');
365
366 write!(self.buffer, "{}", Clear(ClearType::UntilNewLine)).unwrap();
367 self.buffer.push('\r');
370 write!(output, "{}", self.buffer)?;
371 output.flush()?;
372 Ok(())
373 }
374}
375
376fn draw_progress(progress: f32, buffer: &mut String, width: usize) {
377 const CHARS: [char; 9] = [' ', '▏', '▎', '▍', '▌', '▋', '▊', '▉', '█'];
378 const RESOLUTION: usize = CHARS.len() - 1;
379 let ticks = (width as f32 * progress.clamp(0.0, 1.0) * RESOLUTION as f32).round() as usize;
380 let whole = ticks / RESOLUTION;
381 for _ in 0..whole {
382 buffer.push(CHARS[CHARS.len() - 1]);
383 }
384 if whole < width {
385 let fraction = ticks % RESOLUTION;
386 buffer.push(CHARS[fraction]);
387 }
388 for _ in (whole + 1)..width {
389 buffer.push(CHARS[0]);
390 }
391}
392
393struct RefStatus {
394 ref_kind: GitRefKind,
395 symbol: String,
396 tracking_status: TrackingStatus,
397 import_status: ImportStatus,
398}
399
400impl RefStatus {
401 fn new(
402 ref_kind: GitRefKind,
403 symbol: RemoteRefSymbol<'_>,
404 remote_ref: &RemoteRef,
405 ref_target: &RefTarget,
406 repo: &dyn Repo,
407 ) -> Self {
408 let tracking_status = match ref_kind {
409 GitRefKind::Bookmark => {
410 if repo.view().get_remote_bookmark(symbol).is_tracked() {
411 TrackingStatus::Tracked
412 } else {
413 TrackingStatus::Untracked
414 }
415 }
416 GitRefKind::Tag => TrackingStatus::NotApplicable,
417 };
418
419 let import_status = match (remote_ref.target.is_absent(), ref_target.is_absent()) {
420 (true, false) => ImportStatus::New,
421 (false, true) => ImportStatus::Deleted,
422 _ => ImportStatus::Updated,
423 };
424
425 Self {
426 symbol: symbol.to_string(),
427 tracking_status,
428 import_status,
429 ref_kind,
430 }
431 }
432
433 fn output(&self, max_symbol_width: usize, out: &mut dyn Formatter) -> std::io::Result<()> {
434 let tracking_status = match self.tracking_status {
435 TrackingStatus::Tracked => "tracked",
436 TrackingStatus::Untracked => "untracked",
437 TrackingStatus::NotApplicable => "",
438 };
439
440 let import_status = match self.import_status {
441 ImportStatus::New => "new",
442 ImportStatus::Deleted => "deleted",
443 ImportStatus::Updated => "updated",
444 };
445
446 let symbol_width = self.symbol.width();
447 let pad_width = max_symbol_width.saturating_sub(symbol_width);
448 let padded_symbol = format!("{}{:>pad_width$}", self.symbol, "", pad_width = pad_width);
449
450 let label = match self.ref_kind {
451 GitRefKind::Bookmark => "bookmark",
452 GitRefKind::Tag => "tag",
453 };
454
455 write!(out, "{label}: ")?;
456 write!(out.labeled(label), "{padded_symbol}")?;
457 writeln!(out, " [{import_status}] {tracking_status}")
458 }
459}
460
461enum TrackingStatus {
462 Tracked,
463 Untracked,
464 NotApplicable, }
466
467enum ImportStatus {
468 New,
469 Deleted,
470 Updated,
471}
472
473pub fn print_git_export_stats(ui: &Ui, stats: &GitExportStats) -> Result<(), std::io::Error> {
474 if !stats.failed_bookmarks.is_empty() {
475 writeln!(ui.warning_default(), "Failed to export some bookmarks:")?;
476 let mut formatter = ui.stderr_formatter();
477 for (symbol, reason) in &stats.failed_bookmarks {
478 write!(formatter, " ")?;
479 write!(formatter.labeled("bookmark"), "{symbol}")?;
480 for err in iter::successors(Some(reason as &dyn error::Error), |err| err.source()) {
481 write!(formatter, ": {err}")?;
482 }
483 writeln!(formatter)?;
484 }
485 }
486 if !stats.failed_tags.is_empty() {
487 writeln!(ui.warning_default(), "Failed to export some tags:")?;
488 let mut formatter = ui.stderr_formatter();
489 for (symbol, reason) in &stats.failed_tags {
490 write!(formatter, " ")?;
491 write!(formatter.labeled("tag"), "{symbol}")?;
492 for err in iter::successors(Some(reason as &dyn error::Error), |err| err.source()) {
493 write!(formatter, ": {err}")?;
494 }
495 writeln!(formatter)?;
496 }
497 }
498 if itertools::chain(&stats.failed_bookmarks, &stats.failed_tags)
499 .any(|(_, reason)| matches!(reason, FailedRefExportReason::FailedToSet(_)))
500 {
501 writedoc!(
502 ui.hint_default(),
503 r#"
504 Git doesn't allow a branch/tag name that looks like a parent directory of
505 another (e.g. `foo` and `foo/bar`). Try to rename the bookmarks/tags that failed
506 to export or their "parent" bookmarks/tags.
507 "#,
508 )?;
509 }
510 Ok(())
511}
512
513pub fn print_push_stats(ui: &Ui, stats: &GitPushStats) -> io::Result<()> {
514 if !stats.rejected.is_empty() {
515 writeln!(
516 ui.warning_default(),
517 "The following references unexpectedly moved on the remote:"
518 )?;
519 let mut formatter = ui.stderr_formatter();
520 for (reference, reason) in &stats.rejected {
521 write!(formatter, " ")?;
522 write!(formatter.labeled("git_ref"), "{}", reference.as_symbol())?;
523 if let Some(r) = reason {
524 write!(formatter, " (reason: {r})")?;
525 }
526 writeln!(formatter)?;
527 }
528 drop(formatter);
529 writeln!(
530 ui.hint_default(),
531 "Try fetching from the remote, then make the bookmark point to where you want it to \
532 be, and push again.",
533 )?;
534 }
535 if !stats.remote_rejected.is_empty() {
536 writeln!(
537 ui.warning_default(),
538 "The remote rejected the following updates:"
539 )?;
540 let mut formatter = ui.stderr_formatter();
541 for (reference, reason) in &stats.remote_rejected {
542 write!(formatter, " ")?;
543 write!(formatter.labeled("git_ref"), "{}", reference.as_symbol())?;
544 if let Some(r) = reason {
545 write!(formatter, " (reason: {r})")?;
546 }
547 writeln!(formatter)?;
548 }
549 drop(formatter);
550 writeln!(
551 ui.hint_default(),
552 "Try checking if you have permission to push to all the bookmarks."
553 )?;
554 }
555 if !stats.unexported_bookmarks.is_empty() {
556 writeln!(
557 ui.warning_default(),
558 "The following bookmarks couldn't be updated locally:"
559 )?;
560 let mut formatter = ui.stderr_formatter();
561 for (symbol, reason) in &stats.unexported_bookmarks {
562 write!(formatter, " ")?;
563 write!(formatter.labeled("bookmark"), "{symbol}")?;
564 for err in iter::successors(Some(reason as &dyn error::Error), |err| err.source()) {
565 write!(formatter, ": {err}")?;
566 }
567 writeln!(formatter)?;
568 }
569 }
570 Ok(())
571}
572
573#[cfg(test)]
574mod tests {
575 use std::path::MAIN_SEPARATOR;
576
577 use insta::assert_snapshot;
578
579 use super::*;
580
581 #[test]
582 fn test_absolute_git_url() {
583 let temp_dir = testutils::new_temp_dir();
586 let cwd = dunce::canonicalize(temp_dir.path()).unwrap();
587 let cwd_slash = cwd.to_str().unwrap().replace(MAIN_SEPARATOR, "/");
588
589 assert_eq!(
591 absolute_git_url(&cwd, "foo").unwrap(),
592 format!("{cwd_slash}/foo")
593 );
594 assert_eq!(
595 absolute_git_url(&cwd, r"foo\bar").unwrap(),
596 if cfg!(windows) {
597 format!("{cwd_slash}/foo/bar")
598 } else {
599 format!(r"{cwd_slash}/foo\bar")
600 }
601 );
602 assert_eq!(
603 absolute_git_url(&cwd.join("bar"), &format!("{cwd_slash}/foo")).unwrap(),
604 format!("{cwd_slash}/foo")
605 );
606
607 assert_eq!(
609 absolute_git_url(&cwd, "git@example.org:foo/bar.git").unwrap(),
610 "git@example.org:foo/bar.git"
611 );
612 assert_eq!(
614 absolute_git_url(&cwd, "https://example.org/foo.git").unwrap(),
615 "https://example.org/foo.git"
616 );
617 assert_eq!(
619 absolute_git_url(&cwd, "custom://example.org/foo.git").unwrap(),
620 "custom://example.org/foo.git"
621 );
622 assert_eq!(
624 absolute_git_url(&cwd, "https://user:pass@example.org/").unwrap(),
625 "https://user:pass@example.org/"
626 );
627
628 assert_eq!(
630 absolute_git_url(&cwd, "https://example.org/%20%25").unwrap(),
631 "https://example.org/%20%25"
632 );
633 assert!(
635 absolute_git_url(&cwd, "file:///%20%25")
636 .unwrap()
637 .ends_with("/%20%25")
638 );
639 }
640
641 #[test]
642 fn test_git_remote_url_to_web() {
643 let to_web = |s| git_remote_url_to_web(&gix::Url::try_from(s).unwrap());
644
645 assert_eq!(
647 to_web("git@github.com:owner/repo"),
648 Some("https://github.com/owner/repo".to_owned())
649 );
650 assert_eq!(
652 to_web("https://github.com/owner/repo.git"),
653 Some("https://github.com/owner/repo".to_owned())
654 );
655 assert_eq!(
657 to_web("ssh://git@github.com/owner/repo"),
658 Some("https://github.com/owner/repo".to_owned())
659 );
660 assert_eq!(
662 to_web("git://github.com/owner/repo.git"),
663 Some("https://github.com/owner/repo".to_owned())
664 );
665 assert_eq!(to_web("file:///path/to/repo"), None);
667 assert_eq!(to_web("/path/to/repo"), None);
669 }
670
671 #[test]
672 fn test_bar() {
673 let mut buf = String::new();
674 draw_progress(0.0, &mut buf, 10);
675 assert_eq!(buf, " ");
676 buf.clear();
677 draw_progress(1.0, &mut buf, 10);
678 assert_eq!(buf, "██████████");
679 buf.clear();
680 draw_progress(0.5, &mut buf, 10);
681 assert_eq!(buf, "█████ ");
682 buf.clear();
683 draw_progress(0.54, &mut buf, 10);
684 assert_eq!(buf, "█████▍ ");
685 buf.clear();
686 }
687
688 #[test]
689 fn test_update() {
690 let start = Instant::now();
691 let mut progress = Progress::new(start);
692 let mut current_time = start;
693 let mut update = |duration, overall: u64| -> String {
694 current_time += duration;
695 let mut buf = vec![];
696 let mut output = ProgressOutput::for_test(&mut buf, 25);
697 progress
698 .update(
699 current_time,
700 &GitProgress {
701 deltas: (overall, 100),
702 objects: (0, 0),
703 counted_objects: (0, 0),
704 compressed_objects: (0, 0),
705 },
706 &mut output,
707 )
708 .unwrap();
709 String::from_utf8(buf).unwrap()
710 };
711 assert_snapshot!(update(crate::progress::INITIAL_DELAY - Duration::from_millis(1), 1), @"");
713 assert_snapshot!(update(Duration::from_millis(1), 10), @"\u{1b}[?25l\r 10% [█▊ ]\u{1b}[K");
714 assert_snapshot!(update(Duration::from_millis(10), 11), @"");
716 assert_snapshot!(update(Duration::from_millis(10), 12), @"");
717 assert_snapshot!(update(Duration::from_millis(10), 13), @"");
718 assert_snapshot!(update(Duration::from_millis(100), 30), @"\r 30% [█████▍ ]\u{1b}[K");
720 assert_snapshot!(update(Duration::from_millis(30), 40), @"");
723 }
724}