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