Fix permalink-to-line when Git repo root and worktree dir don't coincide (#22003)

Cole Miller created

Closes #21505. This should work if the git dir is an ancestor of the
worktree dir or vice versa.

Release Notes:

- Fixed GitHub permalink-to-line actions when worktree dir and Git dir
aren't the same

Change summary

Cargo.lock                         |  1 
crates/project/Cargo.toml          |  1 
crates/project/src/buffer_store.rs | 96 +++++++++++++++++++++++++++++--
crates/worktree/src/worktree.rs    |  1 
4 files changed, 91 insertions(+), 8 deletions(-)

Detailed changes

Cargo.lock 🔗

@@ -9592,6 +9592,7 @@ dependencies = [
  "tempfile",
  "terminal",
  "text",
+ "toml 0.8.19",
  "unindent",
  "url",
  "util",

crates/project/Cargo.toml 🔗

@@ -68,6 +68,7 @@ snippet.workspace = true
 snippet_provider.workspace = true
 terminal.workspace = true
 text.workspace = true
+toml.workspace = true
 util.workspace = true
 url.workspace = true
 which.workspace = true

crates/project/src/buffer_store.rs 🔗

@@ -5,12 +5,12 @@ use crate::{
     ProjectItem as _, ProjectPath,
 };
 use ::git::{parse_git_remote_url, BuildPermalinkParams, GitHostingProviderRegistry};
-use anyhow::{anyhow, Context as _, Result};
+use anyhow::{anyhow, bail, Context as _, Result};
 use client::Client;
 use collections::{hash_map, HashMap, HashSet};
 use fs::Fs;
 use futures::{channel::oneshot, future::Shared, Future, FutureExt as _, StreamExt};
-use git::{blame::Blame, diff::BufferDiff};
+use git::{blame::Blame, diff::BufferDiff, repository::RepoPath};
 use gpui::{
     AppContext, AsyncAppContext, Context as _, EventEmitter, Model, ModelContext, Subscription,
     Task, WeakModel,
@@ -24,8 +24,16 @@ use language::{
     Buffer, BufferEvent, Capability, DiskState, File as _, Language, Operation,
 };
 use rpc::{proto, AnyProtoClient, ErrorExt as _, TypedEnvelope};
+use serde::Deserialize;
 use smol::channel::Receiver;
-use std::{io, ops::Range, path::Path, str::FromStr as _, sync::Arc, time::Instant};
+use std::{
+    io,
+    ops::Range,
+    path::{Path, PathBuf},
+    str::FromStr as _,
+    sync::Arc,
+    time::Instant,
+};
 use text::{BufferId, LineEnding, Rope};
 use util::{debug_panic, maybe, ResultExt as _, TryFutureExt};
 use worktree::{File, PathChange, ProjectEntryId, UpdatedGitRepositoriesSet, Worktree, WorktreeId};
@@ -1213,11 +1221,36 @@ impl BufferStore {
 
         match file.worktree.read(cx) {
             Worktree::Local(worktree) => {
-                let Some(repo) = worktree.local_git_repo(file.path()) else {
-                    return Task::ready(Err(anyhow!("no repository for buffer found")));
+                let worktree_path = worktree.abs_path().clone();
+                let Some((repo_entry, repo)) =
+                    worktree.repository_for_path(file.path()).and_then(|entry| {
+                        let repo = worktree.get_local_repo(&entry)?.repo().clone();
+                        Some((entry, repo))
+                    })
+                else {
+                    // If we're not in a Git repo, check whether this is a Rust source
+                    // file in the Cargo registry (presumably opened with go-to-definition
+                    // from a normal Rust file). If so, we can put together a permalink
+                    // using crate metadata.
+                    if !buffer
+                        .language()
+                        .is_some_and(|lang| lang.name() == "Rust".into())
+                    {
+                        return Task::ready(Err(anyhow!("no permalink available")));
+                    }
+                    let file_path = worktree_path.join(file.path());
+                    return cx.spawn(|cx| async move {
+                        let provider_registry =
+                            cx.update(GitHostingProviderRegistry::default_global)?;
+                        get_permalink_in_rust_registry_src(provider_registry, file_path, selection)
+                            .map_err(|_| anyhow!("no permalink available"))
+                    });
                 };
 
-                let path = file.path().clone();
+                let path = match repo_entry.relativize(worktree, file.path()) {
+                    Ok(RepoPath(path)) => path,
+                    Err(e) => return Task::ready(Err(e)),
+                };
 
                 cx.spawn(|cx| async move {
                     const REMOTE_NAME: &str = "origin";
@@ -1238,7 +1271,7 @@ impl BufferStore {
 
                     let path = path
                         .to_str()
-                        .context("failed to convert buffer path to string")?;
+                        .ok_or_else(|| anyhow!("failed to convert path to string"))?;
 
                     Ok(provider.build_permalink(
                         remote,
@@ -2432,3 +2465,52 @@ fn deserialize_blame_buffer_response(
         remote_url: response.remote_url,
     })
 }
+
+fn get_permalink_in_rust_registry_src(
+    provider_registry: Arc<GitHostingProviderRegistry>,
+    path: PathBuf,
+    selection: Range<u32>,
+) -> Result<url::Url> {
+    #[derive(Deserialize)]
+    struct CargoVcsGit {
+        sha1: String,
+    }
+
+    #[derive(Deserialize)]
+    struct CargoVcsInfo {
+        git: CargoVcsGit,
+        path_in_vcs: String,
+    }
+
+    #[derive(Deserialize)]
+    struct CargoPackage {
+        repository: String,
+    }
+
+    #[derive(Deserialize)]
+    struct CargoToml {
+        package: CargoPackage,
+    }
+
+    let Some((dir, cargo_vcs_info_json)) = path.ancestors().skip(1).find_map(|dir| {
+        let json = std::fs::read_to_string(dir.join(".cargo_vcs_info.json")).ok()?;
+        Some((dir, json))
+    }) else {
+        bail!("No .cargo_vcs_info.json found in parent directories")
+    };
+    let cargo_vcs_info = serde_json::from_str::<CargoVcsInfo>(&cargo_vcs_info_json)?;
+    let cargo_toml = std::fs::read_to_string(dir.join("Cargo.toml"))?;
+    let manifest = toml::from_str::<CargoToml>(&cargo_toml)?;
+    let (provider, remote) = parse_git_remote_url(provider_registry, &manifest.package.repository)
+        .ok_or_else(|| anyhow!("Failed to parse package.repository field of manifest"))?;
+    let path = PathBuf::from(cargo_vcs_info.path_in_vcs).join(path.strip_prefix(dir).unwrap());
+    let permalink = provider.build_permalink(
+        remote,
+        BuildPermalinkParams {
+            sha: &cargo_vcs_info.git.sha1,
+            path: &path.to_string_lossy(),
+            selection: Some(selection),
+        },
+    );
+    Ok(permalink)
+}

crates/worktree/src/worktree.rs 🔗

@@ -2396,7 +2396,6 @@ impl Snapshot {
             .map(|(path, entry)| (&path.0, entry))
     }
 
-    /// Get the repository whose work directory contains the given path.
     pub fn repository_for_work_directory(&self, path: &Path) -> Option<RepositoryEntry> {
         self.repository_entries
             .get(&RepositoryWorkDirectory(path.into()))