jj_cli/
git_util.rs

1// Copyright 2024 The Jujutsu Authors
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7// https://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15//! Git utilities shared by various commands.
16
17use 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 crossterm::terminal::Clear;
27use crossterm::terminal::ClearType;
28use indoc::writedoc;
29use itertools::Itertools as _;
30use jj_lib::fmt_util::binary_prefix;
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::GitRefKind;
37use jj_lib::git::GitSettings;
38use jj_lib::op_store::RefTarget;
39use jj_lib::op_store::RemoteRef;
40use jj_lib::ref_name::RemoteRefSymbol;
41use jj_lib::repo::ReadonlyRepo;
42use jj_lib::repo::Repo;
43use jj_lib::settings::RemoteSettingsMap;
44use jj_lib::workspace::Workspace;
45use unicode_width::UnicodeWidthStr as _;
46
47use crate::cleanup_guard::CleanupGuard;
48use crate::command_error::CommandError;
49use crate::command_error::cli_error;
50use crate::command_error::user_error;
51use crate::formatter::Formatter;
52use crate::formatter::FormatterExt as _;
53use crate::revset_util::parse_remote_auto_track_bookmarks_map;
54use crate::ui::ProgressOutput;
55use crate::ui::Ui;
56
57pub fn is_colocated_git_workspace(workspace: &Workspace, repo: &ReadonlyRepo) -> bool {
58    let Ok(git_backend) = git::get_git_backend(repo.store()) else {
59        return false;
60    };
61    let Some(git_workdir) = git_backend.git_workdir() else {
62        return false; // Bare repository
63    };
64    if git_workdir == workspace.workspace_root() {
65        return true;
66    }
67    // Colocated workspace should have ".git" directory, file, or symlink. Compare
68    // its parent as the git_workdir might be resolved from the real ".git" path.
69    let Ok(dot_git_path) = dunce::canonicalize(workspace.workspace_root().join(".git")) else {
70        return false;
71    };
72    dunce::canonicalize(git_workdir).ok().as_deref() == dot_git_path.parent()
73}
74
75/// Parses user-specified remote URL or path to absolute form.
76pub fn absolute_git_url(cwd: &Path, source: &str) -> Result<String, CommandError> {
77    // Git appears to turn URL-like source to absolute path if local git directory
78    // exits, and fails because '$PWD/https' is unsupported protocol. Since it would
79    // be tedious to copy the exact git (or libgit2) behavior, we simply let gix
80    // parse the input as URL, rcp-like, or local path.
81    let mut url = gix::url::parse(source.as_ref()).map_err(cli_error)?;
82    url.canonicalize(cwd).map_err(user_error)?;
83    // As of gix 0.68.0, the canonicalized path uses platform-native directory
84    // separator, which isn't compatible with libgit2 on Windows.
85    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    // It's less likely that cwd isn't utf-8, so just fall back to original source.
89    Ok(String::from_utf8(url.to_bstring().into()).unwrap_or_else(|_| source.to_owned()))
90}
91
92// Based on Git's implementation: https://github.com/git/git/blob/43072b4ca132437f21975ac6acc6b72dc22fd398/sideband.c#L178
93pub struct GitSidebandProgressMessageWriter {
94    display_prefix: &'static [u8],
95    suffix: &'static [u8],
96    scratch: Vec<u8>,
97}
98
99impl GitSidebandProgressMessageWriter {
100    pub fn new(ui: &Ui) -> Self {
101        let is_terminal = ui.use_progress_indicator();
102
103        Self {
104            display_prefix: "remote: ".as_bytes(),
105            suffix: if is_terminal { "\x1B[K" } else { "        " }.as_bytes(),
106            scratch: Vec::new(),
107        }
108    }
109
110    pub fn write(&mut self, ui: &Ui, progress_message: &[u8]) -> std::io::Result<()> {
111        let mut index = 0;
112        // Append a suffix to each nonempty line to clear the end of the screen line.
113        loop {
114            let Some(i) = progress_message[index..]
115                .iter()
116                .position(|&c| c == b'\r' || c == b'\n')
117                .map(|i| index + i)
118            else {
119                break;
120            };
121            let line_length = i - index;
122
123            // For messages sent across the packet boundary, there would be a nonempty
124            // "scratch" buffer from last call of this function, and there may be a leading
125            // CR/LF in this message. For this case we should add a clear-to-eol suffix to
126            // clean leftover letters we previously have written on the same line.
127            if !self.scratch.is_empty() && line_length == 0 {
128                self.scratch.extend_from_slice(self.suffix);
129            }
130
131            if self.scratch.is_empty() {
132                self.scratch.extend_from_slice(self.display_prefix);
133            }
134
135            // Do not add the clear-to-eol suffix to empty lines:
136            // For progress reporting we may receive a bunch of percentage updates
137            // followed by '\r' to remain on the same line, and at the end receive a single
138            // '\n' to move to the next line. We should preserve the final
139            // status report line by not appending clear-to-eol suffix to this single line
140            // break.
141            if line_length > 0 {
142                self.scratch.extend_from_slice(&progress_message[index..i]);
143                self.scratch.extend_from_slice(self.suffix);
144            }
145            self.scratch.extend_from_slice(&progress_message[i..i + 1]);
146
147            ui.status().write_all(&self.scratch)?;
148            self.scratch.clear();
149
150            index = i + 1;
151        }
152
153        // Add leftover message to "scratch" buffer to be printed in next call.
154        if index < progress_message.len() {
155            if self.scratch.is_empty() {
156                self.scratch.extend_from_slice(self.display_prefix);
157            }
158            self.scratch.extend_from_slice(&progress_message[index..]);
159        }
160
161        Ok(())
162    }
163
164    pub fn flush(&mut self, ui: &Ui) -> std::io::Result<()> {
165        if !self.scratch.is_empty() {
166            self.scratch.push(b'\n');
167            ui.status().write_all(&self.scratch)?;
168            self.scratch.clear();
169        }
170
171        Ok(())
172    }
173}
174
175pub fn with_remote_git_callbacks<T>(ui: &Ui, f: impl FnOnce(git::RemoteCallbacks<'_>) -> T) -> T {
176    let mut callbacks = git::RemoteCallbacks::default();
177
178    let mut progress_callback;
179    if let Some(mut output) = ui.progress_output() {
180        let mut progress = Progress::new(Instant::now());
181        progress_callback = move |x: &git::Progress| {
182            progress.update(Instant::now(), x, &mut output).ok();
183        };
184        callbacks.progress = Some(&mut progress_callback);
185    }
186
187    let mut sideband_progress_writer = GitSidebandProgressMessageWriter::new(ui);
188    let mut sideband_progress_callback = |progress_message: &[u8]| {
189        sideband_progress_writer.write(ui, progress_message).ok();
190    };
191    callbacks.sideband_progress = Some(&mut sideband_progress_callback);
192
193    let result = f(callbacks);
194    sideband_progress_writer.flush(ui).ok();
195    result
196}
197
198pub fn load_git_import_options(
199    ui: &Ui,
200    git_settings: &GitSettings,
201    remote_settings: &RemoteSettingsMap,
202) -> Result<GitImportOptions, CommandError> {
203    Ok(GitImportOptions {
204        auto_local_bookmark: git_settings.auto_local_bookmark,
205        abandon_unreachable_commits: git_settings.abandon_unreachable_commits,
206        remote_auto_track_bookmarks: parse_remote_auto_track_bookmarks_map(ui, remote_settings)?,
207    })
208}
209
210pub fn print_git_import_stats(
211    ui: &Ui,
212    repo: &dyn Repo,
213    stats: &GitImportStats,
214    show_ref_stats: bool,
215) -> Result<(), CommandError> {
216    let Some(mut formatter) = ui.status_formatter() else {
217        return Ok(());
218    };
219    if show_ref_stats {
220        for (kind, changes) in [
221            (GitRefKind::Bookmark, &stats.changed_remote_bookmarks),
222            (GitRefKind::Tag, &stats.changed_remote_tags),
223        ] {
224            let refs_stats = changes
225                .iter()
226                .map(|(symbol, (remote_ref, ref_target))| {
227                    RefStatus::new(kind, symbol.as_ref(), remote_ref, ref_target, repo)
228                })
229                .collect_vec();
230            let Some(max_width) = refs_stats.iter().map(|x| x.symbol.width()).max() else {
231                continue;
232            };
233            for status in refs_stats {
234                status.output(max_width, &mut *formatter)?;
235            }
236        }
237    }
238
239    if !stats.abandoned_commits.is_empty() {
240        writeln!(
241            formatter,
242            "Abandoned {} commits that are no longer reachable.",
243            stats.abandoned_commits.len()
244        )?;
245    }
246
247    if !stats.failed_ref_names.is_empty() {
248        writeln!(ui.warning_default(), "Failed to import some Git refs:")?;
249        let mut formatter = ui.stderr_formatter();
250        for name in &stats.failed_ref_names {
251            write!(formatter, "  ")?;
252            write!(formatter.labeled("git_ref"), "{name}")?;
253            writeln!(formatter)?;
254        }
255    }
256    if stats
257        .failed_ref_names
258        .iter()
259        .any(|name| name.starts_with(git::RESERVED_REMOTE_REF_NAMESPACE.as_bytes()))
260    {
261        writedoc!(
262            ui.hint_default(),
263            "
264            Git remote named '{name}' is reserved for local Git repository.
265            Use `jj git remote rename` to give a different name.
266            ",
267            name = git::REMOTE_NAME_FOR_LOCAL_GIT_REPO.as_symbol(),
268        )?;
269    }
270
271    Ok(())
272}
273
274pub struct Progress {
275    next_print: Instant,
276    rate: RateEstimate,
277    buffer: String,
278    guard: Option<CleanupGuard>,
279}
280
281impl Progress {
282    pub fn new(now: Instant) -> Self {
283        Self {
284            next_print: now + crate::progress::INITIAL_DELAY,
285            rate: RateEstimate::new(),
286            buffer: String::new(),
287            guard: None,
288        }
289    }
290
291    pub fn update<W: std::io::Write>(
292        &mut self,
293        now: Instant,
294        progress: &git::Progress,
295        output: &mut ProgressOutput<W>,
296    ) -> io::Result<()> {
297        use std::fmt::Write as _;
298
299        if progress.overall == 1.0 {
300            write!(output, "\r{}", Clear(ClearType::CurrentLine))?;
301            output.flush()?;
302            return Ok(());
303        }
304
305        let rate = progress
306            .bytes_downloaded
307            .and_then(|x| self.rate.update(now, x));
308        if now < self.next_print {
309            return Ok(());
310        }
311        self.next_print = now + Duration::from_secs(1) / crate::progress::UPDATE_HZ;
312        if self.guard.is_none() {
313            let guard = output.output_guard(crossterm::cursor::Show.to_string());
314            let guard = CleanupGuard::new(move || {
315                drop(guard);
316            });
317            write!(output, "{}", crossterm::cursor::Hide).ok();
318            self.guard = Some(guard);
319        }
320
321        self.buffer.clear();
322        // Overwrite the current local or sideband progress line if any.
323        self.buffer.push('\r');
324        let control_chars = self.buffer.len();
325        write!(self.buffer, "{: >3.0}% ", 100.0 * progress.overall).unwrap();
326        if let Some(total) = progress.bytes_downloaded {
327            let (scaled, prefix) = binary_prefix(total as f32);
328            write!(self.buffer, "{scaled: >5.1} {prefix}B ").unwrap();
329        }
330        if let Some(estimate) = rate {
331            let (scaled, prefix) = binary_prefix(estimate);
332            write!(self.buffer, "at {scaled: >5.1} {prefix}B/s ").unwrap();
333        }
334
335        let bar_width = output
336            .term_width()
337            .map(usize::from)
338            .unwrap_or(0)
339            .saturating_sub(self.buffer.len() - control_chars + 2);
340        self.buffer.push('[');
341        draw_progress(progress.overall, &mut self.buffer, bar_width);
342        self.buffer.push(']');
343
344        write!(self.buffer, "{}", Clear(ClearType::UntilNewLine)).unwrap();
345        // Move cursor back to the first column so the next sideband message
346        // will overwrite the current progress.
347        self.buffer.push('\r');
348        write!(output, "{}", self.buffer)?;
349        output.flush()?;
350        Ok(())
351    }
352}
353
354fn draw_progress(progress: f32, buffer: &mut String, width: usize) {
355    const CHARS: [char; 9] = [' ', '▏', '▎', '▍', '▌', '▋', '▊', '▉', '█'];
356    const RESOLUTION: usize = CHARS.len() - 1;
357    let ticks = (width as f32 * progress.clamp(0.0, 1.0) * RESOLUTION as f32).round() as usize;
358    let whole = ticks / RESOLUTION;
359    for _ in 0..whole {
360        buffer.push(CHARS[CHARS.len() - 1]);
361    }
362    if whole < width {
363        let fraction = ticks % RESOLUTION;
364        buffer.push(CHARS[fraction]);
365    }
366    for _ in (whole + 1)..width {
367        buffer.push(CHARS[0]);
368    }
369}
370
371struct RateEstimate {
372    state: Option<RateEstimateState>,
373}
374
375impl RateEstimate {
376    pub fn new() -> Self {
377        Self { state: None }
378    }
379
380    /// Compute smoothed rate from an update
381    pub fn update(&mut self, now: Instant, total: u64) -> Option<f32> {
382        if let Some(ref mut state) = self.state {
383            return Some(state.update(now, total));
384        }
385
386        self.state = Some(RateEstimateState {
387            total,
388            avg_rate: None,
389            last_sample: now,
390        });
391        None
392    }
393}
394
395struct RateEstimateState {
396    total: u64,
397    avg_rate: Option<f32>,
398    last_sample: Instant,
399}
400
401impl RateEstimateState {
402    fn update(&mut self, now: Instant, total: u64) -> f32 {
403        let delta = total - self.total;
404        self.total = total;
405        let dt = now - self.last_sample;
406        self.last_sample = now;
407        let sample = delta as f32 / dt.as_secs_f32();
408        match self.avg_rate {
409            None => *self.avg_rate.insert(sample),
410            Some(ref mut avg_rate) => {
411                // From Algorithms for Unevenly Spaced Time Series: Moving
412                // Averages and Other Rolling Operators (Andreas Eckner, 2019)
413                const TIME_WINDOW: f32 = 2.0;
414                let alpha = 1.0 - (-dt.as_secs_f32() / TIME_WINDOW).exp();
415                *avg_rate += alpha * (sample - *avg_rate);
416                *avg_rate
417            }
418        }
419    }
420}
421
422struct RefStatus {
423    ref_kind: GitRefKind,
424    symbol: String,
425    tracking_status: TrackingStatus,
426    import_status: ImportStatus,
427}
428
429impl RefStatus {
430    fn new(
431        ref_kind: GitRefKind,
432        symbol: RemoteRefSymbol<'_>,
433        remote_ref: &RemoteRef,
434        ref_target: &RefTarget,
435        repo: &dyn Repo,
436    ) -> Self {
437        let tracking_status = match ref_kind {
438            GitRefKind::Bookmark => {
439                if repo.view().get_remote_bookmark(symbol).is_tracked() {
440                    TrackingStatus::Tracked
441                } else {
442                    TrackingStatus::Untracked
443                }
444            }
445            GitRefKind::Tag => TrackingStatus::NotApplicable,
446        };
447
448        let import_status = match (remote_ref.target.is_absent(), ref_target.is_absent()) {
449            (true, false) => ImportStatus::New,
450            (false, true) => ImportStatus::Deleted,
451            _ => ImportStatus::Updated,
452        };
453
454        Self {
455            symbol: symbol.to_string(),
456            tracking_status,
457            import_status,
458            ref_kind,
459        }
460    }
461
462    fn output(&self, max_symbol_width: usize, out: &mut dyn Formatter) -> std::io::Result<()> {
463        let tracking_status = match self.tracking_status {
464            TrackingStatus::Tracked => "tracked",
465            TrackingStatus::Untracked => "untracked",
466            TrackingStatus::NotApplicable => "",
467        };
468
469        let import_status = match self.import_status {
470            ImportStatus::New => "new",
471            ImportStatus::Deleted => "deleted",
472            ImportStatus::Updated => "updated",
473        };
474
475        let symbol_width = self.symbol.width();
476        let pad_width = max_symbol_width.saturating_sub(symbol_width);
477        let padded_symbol = format!("{}{:>pad_width$}", self.symbol, "", pad_width = pad_width);
478
479        let label = match self.ref_kind {
480            GitRefKind::Bookmark => "bookmark",
481            GitRefKind::Tag => "tag",
482        };
483
484        write!(out, "{label}: ")?;
485        write!(out.labeled(label), "{padded_symbol}")?;
486        writeln!(out, " [{import_status}] {tracking_status}")
487    }
488}
489
490enum TrackingStatus {
491    Tracked,
492    Untracked,
493    NotApplicable, // for tags
494}
495
496enum ImportStatus {
497    New,
498    Deleted,
499    Updated,
500}
501
502pub fn print_git_export_stats(ui: &Ui, stats: &GitExportStats) -> Result<(), std::io::Error> {
503    if !stats.failed_bookmarks.is_empty() {
504        writeln!(ui.warning_default(), "Failed to export some bookmarks:")?;
505        let mut formatter = ui.stderr_formatter();
506        for (symbol, reason) in &stats.failed_bookmarks {
507            write!(formatter, "  ")?;
508            write!(formatter.labeled("bookmark"), "{symbol}")?;
509            for err in iter::successors(Some(reason as &dyn error::Error), |err| err.source()) {
510                write!(formatter, ": {err}")?;
511            }
512            writeln!(formatter)?;
513        }
514    }
515    if !stats.failed_tags.is_empty() {
516        writeln!(ui.warning_default(), "Failed to export some tags:")?;
517        let mut formatter = ui.stderr_formatter();
518        for (symbol, reason) in &stats.failed_tags {
519            write!(formatter, "  ")?;
520            write!(formatter.labeled("tag"), "{symbol}")?;
521            for err in iter::successors(Some(reason as &dyn error::Error), |err| err.source()) {
522                write!(formatter, ": {err}")?;
523            }
524            writeln!(formatter)?;
525        }
526    }
527    if itertools::chain(&stats.failed_bookmarks, &stats.failed_tags)
528        .any(|(_, reason)| matches!(reason, FailedRefExportReason::FailedToSet(_)))
529    {
530        writedoc!(
531            ui.hint_default(),
532            r#"
533            Git doesn't allow a branch/tag name that looks like a parent directory of
534            another (e.g. `foo` and `foo/bar`). Try to rename the bookmarks/tags that failed
535            to export or their "parent" bookmarks/tags.
536            "#,
537        )?;
538    }
539    Ok(())
540}
541
542#[cfg(test)]
543mod tests {
544    use std::path::MAIN_SEPARATOR;
545
546    use insta::assert_snapshot;
547
548    use super::*;
549
550    #[test]
551    fn test_absolute_git_url() {
552        // gix::Url::canonicalize() works even if the path doesn't exist.
553        // However, we need to ensure that no symlinks exist at the test paths.
554        let temp_dir = testutils::new_temp_dir();
555        let cwd = dunce::canonicalize(temp_dir.path()).unwrap();
556        let cwd_slash = cwd.to_str().unwrap().replace(MAIN_SEPARATOR, "/");
557
558        // Local path
559        assert_eq!(
560            absolute_git_url(&cwd, "foo").unwrap(),
561            format!("{cwd_slash}/foo")
562        );
563        assert_eq!(
564            absolute_git_url(&cwd, r"foo\bar").unwrap(),
565            if cfg!(windows) {
566                format!("{cwd_slash}/foo/bar")
567            } else {
568                format!(r"{cwd_slash}/foo\bar")
569            }
570        );
571        assert_eq!(
572            absolute_git_url(&cwd.join("bar"), &format!("{cwd_slash}/foo")).unwrap(),
573            format!("{cwd_slash}/foo")
574        );
575
576        // rcp-like
577        assert_eq!(
578            absolute_git_url(&cwd, "git@example.org:foo/bar.git").unwrap(),
579            "git@example.org:foo/bar.git"
580        );
581        // URL
582        assert_eq!(
583            absolute_git_url(&cwd, "https://example.org/foo.git").unwrap(),
584            "https://example.org/foo.git"
585        );
586        // Custom scheme isn't an error
587        assert_eq!(
588            absolute_git_url(&cwd, "custom://example.org/foo.git").unwrap(),
589            "custom://example.org/foo.git"
590        );
591        // Password shouldn't be redacted (gix::Url::to_string() would do)
592        assert_eq!(
593            absolute_git_url(&cwd, "https://user:pass@example.org/").unwrap(),
594            "https://user:pass@example.org/"
595        );
596    }
597
598    #[test]
599    fn test_bar() {
600        let mut buf = String::new();
601        draw_progress(0.0, &mut buf, 10);
602        assert_eq!(buf, "          ");
603        buf.clear();
604        draw_progress(1.0, &mut buf, 10);
605        assert_eq!(buf, "██████████");
606        buf.clear();
607        draw_progress(0.5, &mut buf, 10);
608        assert_eq!(buf, "█████     ");
609        buf.clear();
610        draw_progress(0.54, &mut buf, 10);
611        assert_eq!(buf, "█████▍    ");
612        buf.clear();
613    }
614
615    #[test]
616    fn test_update() {
617        let start = Instant::now();
618        let mut progress = Progress::new(start);
619        let mut current_time = start;
620        let mut update = |duration, overall| -> String {
621            current_time += duration;
622            let mut buf = vec![];
623            let mut output = ProgressOutput::for_test(&mut buf, 25);
624            progress
625                .update(
626                    current_time,
627                    &jj_lib::git::Progress {
628                        bytes_downloaded: None,
629                        overall,
630                    },
631                    &mut output,
632                )
633                .unwrap();
634            String::from_utf8(buf).unwrap()
635        };
636        // First output is after the initial delay
637        assert_snapshot!(update(crate::progress::INITIAL_DELAY - Duration::from_millis(1), 0.1), @"");
638        assert_snapshot!(update(Duration::from_millis(1), 0.10), @"\u{1b}[?25l\r 10% [█▊                ]\u{1b}[K");
639        // No updates for the next 30 milliseconds
640        assert_snapshot!(update(Duration::from_millis(10), 0.11), @"");
641        assert_snapshot!(update(Duration::from_millis(10), 0.12), @"");
642        assert_snapshot!(update(Duration::from_millis(10), 0.13), @"");
643        // We get an update now that we go over the threshold
644        assert_snapshot!(update(Duration::from_millis(100), 0.30), @"\r 30% [█████▍            ]\u{1b}[K");
645        // Even though we went over by quite a bit, the new threshold is relative to the
646        // previous output, so we don't get an update here
647        assert_snapshot!(update(Duration::from_millis(30), 0.40), @"");
648    }
649}