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