719 lines · 24.8 KB · Rust
RawDownload
// Copyright 2024 The Jujutsu Authors//// Licensed under the Apache License, Version 2.0 (the "License");// you may not use this file except in compliance with the License.// You may obtain a copy of the License at//// https://www.apache.org/licenses/LICENSE-2.0//// Unless required by applicable law or agreed to in writing, software// distributed under the License is distributed on an "AS IS" BASIS,// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.// See the License for the specific language governing permissions and// limitations under the License. //! Git utilities shared by various commands. use std::error;use std::io;use std::io::Write as _;use std::iter;use std::mem;use std::path::Path;use std::time::Duration;use std::time::Instant; use bstr::ByteSlice as _;use crossterm::terminal::Clear;use crossterm::terminal::ClearType;use indoc::writedoc;use itertools::Itertools as _;use jj_lib::git;use jj_lib::git::FailedRefExportReason;use jj_lib::git::GitExportStats;use jj_lib::git::GitImportOptions;use jj_lib::git::GitImportRefUpdate;use jj_lib::git::GitImportStats;use jj_lib::git::GitProgress;use jj_lib::git::GitPushStats;use jj_lib::git::GitRefKind;use jj_lib::git::GitSettings;use jj_lib::git::GitSidebandLineTerminator;use jj_lib::git::GitSubprocessCallback;use jj_lib::op_store::RemoteRefState;use jj_lib::repo::ReadonlyRepo;use jj_lib::repo::Repo;use jj_lib::settings::RemoteSettingsMap;use jj_lib::workspace::Workspace;use unicode_width::UnicodeWidthStr as _; use crate::cleanup_guard::CleanupGuard;use crate::cli_util::WorkspaceCommandTransaction;use crate::cli_util::print_updated_commits;use crate::command_error::CommandError;use crate::command_error::cli_error;use crate::command_error::user_error;use crate::formatter::Formatter;use crate::formatter::FormatterExt as _;use crate::revset_util::parse_remote_auto_track_bookmarks_map;use crate::ui::ProgressOutput;use crate::ui::Ui; pub fn is_colocated_git_workspace(workspace: &Workspace) -> bool {    let Ok(git_backend) = git::get_git_backend(workspace.repo_loader().store()) else {        return false;    };    let Some(git_workdir) = git_backend.git_workdir() else {        return false; // Bare repository    };    if git_workdir == workspace.workspace_root() {        return true;    }    // Colocated workspace should have ".git" directory, file, or symlink. Compare    // its parent as the git_workdir might be resolved from the real ".git" path.    let Ok(dot_git_path) = dunce::canonicalize(workspace.workspace_root().join(".git")) else {        return false;    };    dunce::canonicalize(git_workdir).ok().as_deref() == dot_git_path.parent()} /// Parses user-specified remote URL or path to absolute form.pub fn absolute_git_url(cwd: &Path, source: &str) -> Result<String, CommandError> {    // Git appears to turn URL-like source to absolute path if local git directory    // exits, and fails because '$PWD/https' is unsupported protocol. Since it would    // be tedious to copy the exact git (or libgit2) behavior, we simply let gix    // parse the input as URL, rcp-like, or local path.    let mut url = gix::url::parse(source.as_ref()).map_err(cli_error)?;    url.canonicalize(cwd).map_err(user_error)?;    // As of gix 0.68.0, the canonicalized path uses platform-native directory    // separator, which isn't compatible with libgit2 on Windows.    if url.scheme == gix::url::Scheme::File {        url.path = gix::path::to_unix_separators_on_windows(mem::take(&mut url.path)).into_owned();    }    // It's less likely that cwd isn't utf-8, so just fall back to original source.    Ok(String::from_utf8(url.to_bstring().into()).unwrap_or_else(|_| source.to_owned()))} /// Converts a git remote URL to a normalized HTTPS URL for web browsing.////// Returns `None` if the URL cannot be converted.fn git_remote_url_to_web(url: &gix::Url) -> Option<String> {    if url.scheme == gix::url::Scheme::File || url.host().is_none() {        return None;    }     let host = url.host()?;    let path = url.path.to_str().ok()?;    let path = path.trim_matches('/');    let path = path.strip_suffix(".git").unwrap_or(path);     Some(format!("https://{host}/{path}"))} /// Returns the web URL for a git remote.////// Attempts to convert the remote's URL to an HTTPS web URL./// Returns `None` if the remote doesn't exist or its URL cannot be converted.pub fn get_remote_web_url(repo: &ReadonlyRepo, remote_name: &str) -> Option<String> {    let git_repo = git::get_git_repo(repo.store()).ok()?;    let remote = git_repo.try_find_remote(remote_name)?.ok()?;    let url = remote        .url(gix::remote::Direction::Fetch)        .or_else(|| remote.url(gix::remote::Direction::Push))?;    git_remote_url_to_web(url)} /// [`Ui`] adapter to forward Git command outputs.pub struct GitSubprocessUi<'a> {    // Don't hold locked ui.status() which could block tracing output in    // different threads.    ui: &'a Ui,    progress_output: Option<ProgressOutput<io::Stderr>>,    progress: Progress,    // Sequence to erase line towards end.    erase_end: &'static [u8],} impl<'a> GitSubprocessUi<'a> {    pub fn new(ui: &'a Ui) -> Self {        let progress_output = ui.progress_output();        let is_terminal = progress_output.is_some();        Self {            ui,            progress_output,            progress: Progress::new(Instant::now()),            erase_end: if is_terminal { b"\x1B[K" } else { b"        " },        }    }     fn write_sideband(        &self,        prefix: &[u8],        message: &[u8],        term: Option<GitSidebandLineTerminator>,    ) -> io::Result<()> {        // TODO: maybe progress should be temporarily cleared if there are        // sideband lines to write.        let mut scratch =            Vec::with_capacity(prefix.len() + message.len() + self.erase_end.len() + 1);        scratch.extend_from_slice(prefix);        scratch.extend_from_slice(message);        // Do not erase the current line by new empty line: For progress        // reporting, we may receive a bunch of percentage updates followed by        // '\r' to remain on the same line, and at the end receive a single '\n'        // to move to the next line. We should preserve the final status report        // line by not appending erase_end sequence to this single line break.        if !message.is_empty() {            scratch.extend_from_slice(self.erase_end);        }        // It's unlikely, but don't leave message without newline.        scratch.push(term.map_or(b'\n', |t| t.as_byte()));        self.ui.status().write_all(&scratch)    }} impl GitSubprocessCallback for GitSubprocessUi<'_> {    fn needs_progress(&self) -> bool {        self.progress_output.is_some()    }     fn progress(&mut self, progress: &GitProgress) -> io::Result<()> {        if let Some(output) = &mut self.progress_output {            self.progress.update(Instant::now(), progress, output)        } else {            Ok(())        }    }     fn local_sideband(        &mut self,        message: &[u8],        term: Option<GitSidebandLineTerminator>,    ) -> io::Result<()> {        self.write_sideband(b"git: ", message, term)    }     fn remote_sideband(        &mut self,        message: &[u8],        term: Option<GitSidebandLineTerminator>,    ) -> io::Result<()> {        self.write_sideband(b"remote: ", message, term)    }} pub fn load_git_import_options(    ui: &Ui,    git_settings: &GitSettings,    remote_settings: &RemoteSettingsMap,) -> Result<GitImportOptions, CommandError> {    Ok(GitImportOptions {        abandon_unreachable_commits: git_settings.abandon_unreachable_commits,        record_synthetic_predecessors: git_settings.record_synthetic_predecessors,        remote_auto_track_bookmarks: parse_remote_auto_track_bookmarks_map(ui, remote_settings)?,    })} pub fn print_git_import_stats(    ui: &Ui,    tx: &WorkspaceCommandTransaction<'_>,    stats: &GitImportStats,) -> Result<(), CommandError> {    if let Some(mut formatter) = ui.status_formatter() {        print_imported_changes(formatter.as_mut(), tx, stats)?;    }    print_failed_git_import(ui, stats)?;    Ok(())} fn print_imported_changes(    formatter: &mut dyn Formatter,    tx: &WorkspaceCommandTransaction<'_>,    stats: &GitImportStats,) -> Result<(), CommandError> {    for (kind, changes) in [        (GitRefKind::Bookmark, &stats.changed_remote_bookmarks),        (GitRefKind::Tag, &stats.changed_remote_tags),    ] {        let refs_stats = changes            .iter()            .map(|update| RefStatus::new(kind, update, tx.repo()))            .collect_vec();        let Some(max_width) = refs_stats.iter().map(|x| x.symbol.width()).max() else {            continue;        };        for status in refs_stats {            status.output(max_width, formatter)?;        }    }     if !stats.abandoned_commits.is_empty() {        writeln!(            formatter,            "Abandoned {} commits that are no longer reachable:",            stats.abandoned_commits.len()        )?;        let template = tx.commit_summary_template();        print_updated_commits(formatter, &template, &stats.abandoned_commits)?;    }    if !stats.rewritten_commit_ids.is_empty() {        writeln!(            formatter,            "Updated {} rewritten commits.",            stats.rewritten_commit_ids.len()        )?;    }     Ok(())} fn print_failed_git_import(ui: &Ui, stats: &GitImportStats) -> Result<(), CommandError> {    if !stats.failed_ref_names.is_empty() {        writeln!(ui.warning_default(), "Failed to import some Git refs:")?;        let mut formatter = ui.stderr_formatter();        for name in &stats.failed_ref_names {            write!(formatter, "  ")?;            write!(formatter.labeled("git_ref"), "{name}")?;            writeln!(formatter)?;        }    }    if stats        .failed_ref_names        .iter()        .any(|name| name.starts_with(git::RESERVED_REMOTE_REF_NAMESPACE.as_bytes()))    {        writedoc!(            ui.hint_default(),            "            Git remote named '{name}' is reserved for local Git repository.            Use `jj git remote rename` to give a different name.            ",            name = git::REMOTE_NAME_FOR_LOCAL_GIT_REPO.as_symbol(),        )?;    }    Ok(())} /// Prints only the summary of git import stats (abandoned count, failed refs)./// Use this when a WorkspaceCommandTransaction is not available.pub fn print_git_import_stats_summary(ui: &Ui, stats: &GitImportStats) -> Result<(), CommandError> {    if let Some(mut formatter) = ui.status_formatter() {        if !stats.abandoned_commits.is_empty() {            writeln!(                formatter,                "Abandoned {} commits that are no longer reachable.",                stats.abandoned_commits.len()            )?;        }        if !stats.rewritten_commit_ids.is_empty() {            writeln!(                formatter,                "Updated {} rewritten commits.",                stats.rewritten_commit_ids.len()            )?;        }    }    print_failed_git_import(ui, stats)?;    Ok(())} pub struct Progress {    next_print: Instant,    buffer: String,    guard: Option<CleanupGuard>,} impl Progress {    pub fn new(now: Instant) -> Self {        Self {            next_print: now + crate::progress::INITIAL_DELAY,            buffer: String::new(),            guard: None,        }    }     pub fn update<W: std::io::Write>(        &mut self,        now: Instant,        progress: &GitProgress,        output: &mut ProgressOutput<W>,    ) -> io::Result<()> {        use std::fmt::Write as _;         if progress.overall() == 1.0 {            write!(output, "\r{}", Clear(ClearType::CurrentLine))?;            output.flush()?;            return Ok(());        }         if now < self.next_print {            return Ok(());        }        self.next_print = now + Duration::from_secs(1) / crate::progress::UPDATE_HZ;        if self.guard.is_none() {            let guard = output.output_guard(crossterm::cursor::Show.to_string());            let guard = CleanupGuard::new(move || {                drop(guard);            });            write!(output, "{}", crossterm::cursor::Hide).ok();            self.guard = Some(guard);        }         self.buffer.clear();        // Overwrite the current local or sideband progress line if any.        self.buffer.push('\r');        let control_chars = self.buffer.len();        write!(self.buffer, "{: >3.0}% ", 100.0 * progress.overall()).unwrap();         let bar_width = output            .term_width()            .map(usize::from)            .unwrap_or(0)            .saturating_sub(self.buffer.len() - control_chars + 2);        self.buffer.push('[');        draw_progress(progress.overall(), &mut self.buffer, bar_width);        self.buffer.push(']');         write!(self.buffer, "{}", Clear(ClearType::UntilNewLine)).unwrap();        // Move cursor back to the first column so the next sideband message        // will overwrite the current progress.        self.buffer.push('\r');        write!(output, "{}", self.buffer)?;        output.flush()?;        Ok(())    }} fn draw_progress(progress: f32, buffer: &mut String, width: usize) {    const CHARS: [char; 9] = [' ', '▏', '▎', '▍', '▌', '▋', '▊', '▉', '█'];    const RESOLUTION: usize = CHARS.len() - 1;    let ticks = (width as f32 * progress.clamp(0.0, 1.0) * RESOLUTION as f32).round() as usize;    let whole = ticks / RESOLUTION;    for _ in 0..whole {        buffer.push(CHARS[CHARS.len() - 1]);    }    if whole < width {        let fraction = ticks % RESOLUTION;        buffer.push(CHARS[fraction]);    }    for _ in (whole + 1)..width {        buffer.push(CHARS[0]);    }} struct RefStatus {    ref_kind: GitRefKind,    symbol: String,    remote_ref_state: RemoteRefState,    import_status: ImportStatus,} impl RefStatus {    fn new(ref_kind: GitRefKind, update: &GitImportRefUpdate, repo: &dyn Repo) -> Self {        let new_remote_ref = match ref_kind {            GitRefKind::Bookmark => repo.view().get_remote_bookmark(update.symbol.as_ref()),            GitRefKind::Tag => repo.view().get_remote_tag(update.symbol.as_ref()),        };         let import_status = match (            update.old_remote_ref.target.is_absent(),            update.new_target.is_absent(),        ) {            (true, false) => ImportStatus::New,            (false, true) => ImportStatus::Deleted,            _ => ImportStatus::Updated,        };         Self {            symbol: update.symbol.to_string(),            remote_ref_state: new_remote_ref.state,            import_status,            ref_kind,        }    }     fn output(&self, max_symbol_width: usize, out: &mut dyn Formatter) -> std::io::Result<()> {        let tracking_status = match self.remote_ref_state {            RemoteRefState::New => "untracked",            RemoteRefState::Tracked => "tracked",        };         let import_status = match self.import_status {            ImportStatus::New => "new",            ImportStatus::Deleted => "deleted",            ImportStatus::Updated => "updated",        };         let symbol_width = self.symbol.width();        let pad_width = max_symbol_width.saturating_sub(symbol_width);        let padded_symbol = format!("{}{:>pad_width$}", self.symbol, "", pad_width = pad_width);         let label = match self.ref_kind {            GitRefKind::Bookmark => "bookmark",            GitRefKind::Tag => "tag",        };         write!(out, "{label}: ")?;        write!(out.labeled(label), "{padded_symbol}")?;        writeln!(out, " [{import_status}] {tracking_status}")    }} enum ImportStatus {    New,    Deleted,    Updated,} pub fn print_git_export_stats(ui: &Ui, stats: &GitExportStats) -> Result<(), std::io::Error> {    if !stats.failed_bookmarks.is_empty() {        writeln!(ui.warning_default(), "Failed to export some bookmarks:")?;        let mut formatter = ui.stderr_formatter();        for (symbol, reason) in &stats.failed_bookmarks {            write!(formatter, "  ")?;            write!(formatter.labeled("bookmark"), "{symbol}")?;            for err in iter::successors(Some(reason as &dyn error::Error), |err| err.source()) {                write!(formatter, ": {err}")?;            }            writeln!(formatter)?;        }    }    if !stats.failed_tags.is_empty() {        writeln!(ui.warning_default(), "Failed to export some tags:")?;        let mut formatter = ui.stderr_formatter();        for (symbol, reason) in &stats.failed_tags {            write!(formatter, "  ")?;            write!(formatter.labeled("tag"), "{symbol}")?;            for err in iter::successors(Some(reason as &dyn error::Error), |err| err.source()) {                write!(formatter, ": {err}")?;            }            writeln!(formatter)?;        }    }    if itertools::chain(&stats.failed_bookmarks, &stats.failed_tags)        .any(|(_, reason)| matches!(reason, FailedRefExportReason::FailedToSet(_)))    {        writedoc!(            ui.hint_default(),            r#"            Git doesn't allow a branch/tag name that looks like a parent directory of            another (e.g. `foo` and `foo/bar`). Try to rename the bookmarks/tags that failed

Lines 1–500 of 719.