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