794 lines · 27.3 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::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, repo: &ReadonlyRepo) -> bool {    let Ok(git_backend) = git::get_git_backend(repo.store()) else {        return false;    };     let dot_git_path = workspace.workspace_root().join(".git");    let dot_git_exists = dot_git_path.exists();     // Check if the git backend has a working directory (non-bare)    if let Some(git_workdir) = git_backend.git_workdir() {        // Primary colocated workspace: git workdir matches workspace root        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.        if let Ok(dot_git_canonical) = dunce::canonicalize(&dot_git_path)            && dunce::canonicalize(git_workdir).ok().as_deref() == dot_git_canonical.parent()        {            return true;        }         // Fall through to check if this might be a secondary colocated        // workspace (git worktree) created with `jj workspace add`    } else {        // Bare repository - can't be colocated        return false;    }     // Check for secondary colocated workspaces (git worktrees) by comparing the    // common_dir of the workspace's .git with jj's backing repo. This only applies    // to gitlink files (.git files pointing to worktree directories), not .git    // directories.    tracing::debug!(        ?dot_git_path,        exists = dot_git_exists,        "Checking for worktree colocation"    );    let is_gitlink = dot_git_exists        && dot_git_path            .symlink_metadata()            .map(|m| m.is_file())            .unwrap_or(false);    if is_gitlink {        match gix::ThreadSafeRepository::open_opts(&dot_git_path, gix::open::Options::isolated()) {            Ok(workspace_repo) => {                let workspace_common_dir = workspace_repo.to_thread_local().common_dir().to_owned();                let store_common_dir = git_backend.git_repo().common_dir().to_owned();                tracing::debug!(                    ?workspace_common_dir,                    ?store_common_dir,                    "Comparing common_dirs"                );                if let (Ok(ws_canonical), Ok(store_canonical)) = (                    dunce::canonicalize(&workspace_common_dir),                    dunce::canonicalize(&store_common_dir),                ) {                    tracing::debug!(?ws_canonical, ?store_canonical, "Canonicalized common_dirs");                    if ws_canonical == store_canonical {                        return true;                    }                }                // .git opened successfully but common_dir doesn't match - not                // colocated            }            Err(e) => {                tracing::debug!(?e, "Failed to open workspace as git repo");                // Broken worktree - not colocated            }        }        return false;    }     false} /// 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,    tracking_status: TrackingStatus,    import_status: ImportStatus,} impl RefStatus {    fn new(ref_kind: GitRefKind, update: &GitImportRefUpdate, repo: &dyn Repo) -> Self {        let tracking_status = match ref_kind {            GitRefKind::Bookmark => {                if repo                    .view()                    .get_remote_bookmark(update.symbol.as_ref())                    .is_tracked()                {                    TrackingStatus::Tracked                } else {                    TrackingStatus::Untracked                }            }            GitRefKind::Tag => TrackingStatus::NotApplicable,        };         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(),            tracking_status,            import_status,            ref_kind,        }

Lines 1–500 of 794.