repository.rs

  1use crate::status::FileStatus;
  2use crate::GitHostingProviderRegistry;
  3use crate::{blame::Blame, status::GitStatus};
  4use anyhow::{anyhow, Context, Result};
  5use collections::{HashMap, HashSet};
  6use git2::BranchType;
  7use gpui::SharedString;
  8use parking_lot::Mutex;
  9use rope::Rope;
 10use std::borrow::Borrow;
 11use std::io::Write as _;
 12use std::process::Stdio;
 13use std::sync::LazyLock;
 14use std::{
 15    cmp::Ordering,
 16    path::{Component, Path, PathBuf},
 17    sync::Arc,
 18};
 19use sum_tree::MapSeekTarget;
 20use util::command::new_std_command;
 21use util::ResultExt;
 22
 23#[derive(Clone, Debug, Hash, PartialEq, Eq)]
 24pub struct Branch {
 25    pub is_head: bool,
 26    pub name: SharedString,
 27    pub upstream: Option<Upstream>,
 28    pub most_recent_commit: Option<CommitSummary>,
 29}
 30
 31impl Branch {
 32    pub fn priority_key(&self) -> (bool, Option<i64>) {
 33        (
 34            self.is_head,
 35            self.most_recent_commit
 36                .as_ref()
 37                .map(|commit| commit.commit_timestamp),
 38        )
 39    }
 40}
 41
 42#[derive(Clone, Debug, Hash, PartialEq, Eq)]
 43pub struct Upstream {
 44    pub ref_name: SharedString,
 45    pub tracking: Option<UpstreamTracking>,
 46}
 47
 48#[derive(Clone, Debug, Hash, PartialEq, Eq)]
 49pub struct UpstreamTracking {
 50    pub ahead: u32,
 51    pub behind: u32,
 52}
 53
 54#[derive(Clone, Debug, Hash, PartialEq, Eq)]
 55pub struct CommitSummary {
 56    pub sha: SharedString,
 57    pub subject: SharedString,
 58    /// This is a unix timestamp
 59    pub commit_timestamp: i64,
 60}
 61
 62#[derive(Clone, Debug, Hash, PartialEq, Eq)]
 63pub struct CommitDetails {
 64    pub sha: SharedString,
 65    pub message: SharedString,
 66    pub commit_timestamp: i64,
 67    pub committer_email: SharedString,
 68    pub committer_name: SharedString,
 69}
 70
 71pub enum ResetMode {
 72    // reset the branch pointer, leave index and worktree unchanged
 73    // (this will make it look like things that were committed are now
 74    // staged)
 75    Soft,
 76    // reset the branch pointer and index, leave worktree unchanged
 77    // (this makes it look as though things that were committed are now
 78    // unstaged)
 79    Mixed,
 80}
 81
 82pub trait GitRepository: Send + Sync {
 83    fn reload_index(&self);
 84
 85    /// Returns the contents of an entry in the repository's index, or None if there is no entry for the given path.
 86    ///
 87    /// Note that for symlink entries, this will return the contents of the symlink, not the target.
 88    fn load_index_text(&self, path: &RepoPath) -> Option<String>;
 89
 90    /// Returns the contents of an entry in the repository's HEAD, or None if HEAD does not exist or has no entry for the given path.
 91    ///
 92    /// Note that for symlink entries, this will return the contents of the symlink, not the target.
 93    fn load_committed_text(&self, path: &RepoPath) -> Option<String>;
 94
 95    fn set_index_text(&self, path: &RepoPath, content: Option<String>) -> anyhow::Result<()>;
 96
 97    /// Returns the URL of the remote with the given name.
 98    fn remote_url(&self, name: &str) -> Option<String>;
 99
100    /// Returns the SHA of the current HEAD.
101    fn head_sha(&self) -> Option<String>;
102
103    fn merge_head_shas(&self) -> Vec<String>;
104
105    /// Returns the list of git statuses, sorted by path
106    fn status(&self, path_prefixes: &[RepoPath]) -> Result<GitStatus>;
107
108    fn branches(&self) -> Result<Vec<Branch>>;
109    fn change_branch(&self, _: &str) -> Result<()>;
110    fn create_branch(&self, _: &str) -> Result<()>;
111    fn branch_exits(&self, _: &str) -> Result<bool>;
112
113    fn reset(&self, commit: &str, mode: ResetMode) -> Result<()>;
114    fn checkout_files(&self, commit: &str, paths: &[RepoPath]) -> Result<()>;
115
116    fn show(&self, commit: &str) -> Result<CommitDetails>;
117
118    fn blame(&self, path: &Path, content: Rope) -> Result<crate::blame::Blame>;
119
120    /// Returns the absolute path to the repository. For worktrees, this will be the path to the
121    /// worktree's gitdir within the main repository (typically `.git/worktrees/<name>`).
122    fn path(&self) -> PathBuf;
123
124    /// Returns the absolute path to the ".git" dir for the main repository, typically a `.git`
125    /// folder. For worktrees, this will be the path to the repository the worktree was created
126    /// from. Otherwise, this is the same value as `path()`.
127    ///
128    /// Git documentation calls this the "commondir", and for git CLI is overridden by
129    /// `GIT_COMMON_DIR`.
130    fn main_repository_path(&self) -> PathBuf;
131
132    /// Updates the index to match the worktree at the given paths.
133    ///
134    /// If any of the paths have been deleted from the worktree, they will be removed from the index if found there.
135    fn stage_paths(&self, paths: &[RepoPath]) -> Result<()>;
136    /// Updates the index to match HEAD at the given paths.
137    ///
138    /// If any of the paths were previously staged but do not exist in HEAD, they will be removed from the index.
139    fn unstage_paths(&self, paths: &[RepoPath]) -> Result<()>;
140
141    fn commit(&self, message: &str, name_and_email: Option<(&str, &str)>) -> Result<()>;
142}
143
144impl std::fmt::Debug for dyn GitRepository {
145    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
146        f.debug_struct("dyn GitRepository<...>").finish()
147    }
148}
149
150pub struct RealGitRepository {
151    pub repository: Mutex<git2::Repository>,
152    pub git_binary_path: PathBuf,
153    hosting_provider_registry: Arc<GitHostingProviderRegistry>,
154}
155
156impl RealGitRepository {
157    pub fn new(
158        repository: git2::Repository,
159        git_binary_path: Option<PathBuf>,
160        hosting_provider_registry: Arc<GitHostingProviderRegistry>,
161    ) -> Self {
162        Self {
163            repository: Mutex::new(repository),
164            git_binary_path: git_binary_path.unwrap_or_else(|| PathBuf::from("git")),
165            hosting_provider_registry,
166        }
167    }
168}
169
170// https://git-scm.com/book/en/v2/Git-Internals-Git-Objects
171const GIT_MODE_SYMLINK: u32 = 0o120000;
172
173impl GitRepository for RealGitRepository {
174    fn reload_index(&self) {
175        if let Ok(mut index) = self.repository.lock().index() {
176            _ = index.read(false);
177        }
178    }
179
180    fn path(&self) -> PathBuf {
181        let repo = self.repository.lock();
182        repo.path().into()
183    }
184
185    fn main_repository_path(&self) -> PathBuf {
186        let repo = self.repository.lock();
187        repo.commondir().into()
188    }
189
190    fn show(&self, commit: &str) -> Result<CommitDetails> {
191        let repo = self.repository.lock();
192        let Ok(commit) = repo.revparse_single(commit)?.into_commit() else {
193            anyhow::bail!("{} is not a commit", commit);
194        };
195        let details = CommitDetails {
196            sha: commit.id().to_string().into(),
197            message: String::from_utf8_lossy(commit.message_raw_bytes())
198                .to_string()
199                .into(),
200            commit_timestamp: commit.time().seconds(),
201            committer_email: String::from_utf8_lossy(commit.committer().email_bytes())
202                .to_string()
203                .into(),
204            committer_name: String::from_utf8_lossy(commit.committer().name_bytes())
205                .to_string()
206                .into(),
207        };
208        Ok(details)
209    }
210
211    fn reset(&self, commit: &str, mode: ResetMode) -> Result<()> {
212        let working_directory = self
213            .repository
214            .lock()
215            .workdir()
216            .context("failed to read git work directory")?
217            .to_path_buf();
218
219        let mode_flag = match mode {
220            ResetMode::Mixed => "--mixed",
221            ResetMode::Soft => "--soft",
222        };
223
224        let output = new_std_command(&self.git_binary_path)
225            .current_dir(&working_directory)
226            .args(["reset", mode_flag, commit])
227            .output()?;
228        if !output.status.success() {
229            return Err(anyhow!(
230                "Failed to reset:\n{}",
231                String::from_utf8_lossy(&output.stderr)
232            ));
233        }
234        Ok(())
235    }
236
237    fn checkout_files(&self, commit: &str, paths: &[RepoPath]) -> Result<()> {
238        if paths.is_empty() {
239            return Ok(());
240        }
241        let working_directory = self
242            .repository
243            .lock()
244            .workdir()
245            .context("failed to read git work directory")?
246            .to_path_buf();
247
248        let output = new_std_command(&self.git_binary_path)
249            .current_dir(&working_directory)
250            .args(["checkout", commit, "--"])
251            .args(paths.iter().map(|path| path.as_ref()))
252            .output()?;
253        if !output.status.success() {
254            return Err(anyhow!(
255                "Failed to checkout files:\n{}",
256                String::from_utf8_lossy(&output.stderr)
257            ));
258        }
259        Ok(())
260    }
261
262    fn load_index_text(&self, path: &RepoPath) -> Option<String> {
263        fn logic(repo: &git2::Repository, path: &RepoPath) -> Result<Option<String>> {
264            const STAGE_NORMAL: i32 = 0;
265            let index = repo.index()?;
266
267            // This check is required because index.get_path() unwraps internally :(
268            check_path_to_repo_path_errors(path)?;
269
270            let oid = match index.get_path(path, STAGE_NORMAL) {
271                Some(entry) if entry.mode != GIT_MODE_SYMLINK => entry.id,
272                _ => return Ok(None),
273            };
274
275            let content = repo.find_blob(oid)?.content().to_owned();
276            Ok(Some(String::from_utf8(content)?))
277        }
278
279        match logic(&self.repository.lock(), path) {
280            Ok(value) => return value,
281            Err(err) => log::error!("Error loading index text: {:?}", err),
282        }
283        None
284    }
285
286    fn load_committed_text(&self, path: &RepoPath) -> Option<String> {
287        let repo = self.repository.lock();
288        let head = repo.head().ok()?.peel_to_tree().log_err()?;
289        let oid = head.get_path(path).ok()?.id();
290        let content = repo.find_blob(oid).log_err()?.content().to_owned();
291        let content = String::from_utf8(content).log_err()?;
292        Some(content)
293    }
294
295    fn set_index_text(&self, path: &RepoPath, content: Option<String>) -> anyhow::Result<()> {
296        let working_directory = self
297            .repository
298            .lock()
299            .workdir()
300            .context("failed to read git work directory")?
301            .to_path_buf();
302        if let Some(content) = content {
303            let mut child = new_std_command(&self.git_binary_path)
304                .current_dir(&working_directory)
305                .args(["hash-object", "-w", "--stdin"])
306                .stdin(Stdio::piped())
307                .stdout(Stdio::piped())
308                .spawn()?;
309            child.stdin.take().unwrap().write_all(content.as_bytes())?;
310            let output = child.wait_with_output()?.stdout;
311            let sha = String::from_utf8(output)?;
312
313            log::debug!("indexing SHA: {sha}, path {path:?}");
314
315            let status = new_std_command(&self.git_binary_path)
316                .current_dir(&working_directory)
317                .args(["update-index", "--add", "--cacheinfo", "100644", &sha])
318                .arg(path.as_ref())
319                .status()?;
320
321            if !status.success() {
322                return Err(anyhow!("Failed to add to index: {status:?}"));
323            }
324        } else {
325            let status = new_std_command(&self.git_binary_path)
326                .current_dir(&working_directory)
327                .args(["update-index", "--force-remove"])
328                .arg(path.as_ref())
329                .status()?;
330
331            if !status.success() {
332                return Err(anyhow!("Failed to remove from index: {status:?}"));
333            }
334        }
335
336        Ok(())
337    }
338
339    fn remote_url(&self, name: &str) -> Option<String> {
340        let repo = self.repository.lock();
341        let remote = repo.find_remote(name).ok()?;
342        remote.url().map(|url| url.to_string())
343    }
344
345    fn head_sha(&self) -> Option<String> {
346        Some(self.repository.lock().head().ok()?.target()?.to_string())
347    }
348
349    fn merge_head_shas(&self) -> Vec<String> {
350        let mut shas = Vec::default();
351        self.repository
352            .lock()
353            .mergehead_foreach(|oid| {
354                shas.push(oid.to_string());
355                true
356            })
357            .ok();
358        shas
359    }
360
361    fn status(&self, path_prefixes: &[RepoPath]) -> Result<GitStatus> {
362        let working_directory = self
363            .repository
364            .lock()
365            .workdir()
366            .context("failed to read git work directory")?
367            .to_path_buf();
368        GitStatus::new(&self.git_binary_path, &working_directory, path_prefixes)
369    }
370
371    fn branch_exits(&self, name: &str) -> Result<bool> {
372        let repo = self.repository.lock();
373        let branch = repo.find_branch(name, BranchType::Local);
374        match branch {
375            Ok(_) => Ok(true),
376            Err(e) => match e.code() {
377                git2::ErrorCode::NotFound => Ok(false),
378                _ => Err(anyhow!(e)),
379            },
380        }
381    }
382
383    fn branches(&self) -> Result<Vec<Branch>> {
384        let working_directory = self
385            .repository
386            .lock()
387            .workdir()
388            .context("failed to read git work directory")?
389            .to_path_buf();
390        let fields = [
391            "%(HEAD)",
392            "%(objectname)",
393            "%(refname)",
394            "%(upstream)",
395            "%(upstream:track)",
396            "%(committerdate:unix)",
397            "%(contents:subject)",
398        ]
399        .join("%00");
400        let args = vec!["for-each-ref", "refs/heads/**/*", "--format", &fields];
401
402        let output = new_std_command(&self.git_binary_path)
403            .current_dir(&working_directory)
404            .args(args)
405            .output()?;
406
407        if !output.status.success() {
408            return Err(anyhow!(
409                "Failed to git git branches:\n{}",
410                String::from_utf8_lossy(&output.stderr)
411            ));
412        }
413
414        let input = String::from_utf8_lossy(&output.stdout);
415
416        let mut branches = parse_branch_input(&input)?;
417        if branches.is_empty() {
418            let args = vec!["symbolic-ref", "--quiet", "--short", "HEAD"];
419
420            let output = new_std_command(&self.git_binary_path)
421                .current_dir(&working_directory)
422                .args(args)
423                .output()?;
424
425            // git symbolic-ref returns a non-0 exit code if HEAD points
426            // to something other than a branch
427            if output.status.success() {
428                let name = String::from_utf8_lossy(&output.stdout).trim().to_string();
429
430                branches.push(Branch {
431                    name: name.into(),
432                    is_head: true,
433                    upstream: None,
434                    most_recent_commit: None,
435                });
436            }
437        }
438
439        Ok(branches)
440    }
441
442    fn change_branch(&self, name: &str) -> Result<()> {
443        let repo = self.repository.lock();
444        let revision = repo.find_branch(name, BranchType::Local)?;
445        let revision = revision.get();
446        let as_tree = revision.peel_to_tree()?;
447        repo.checkout_tree(as_tree.as_object(), None)?;
448        repo.set_head(
449            revision
450                .name()
451                .ok_or_else(|| anyhow!("Branch name could not be retrieved"))?,
452        )?;
453        Ok(())
454    }
455
456    fn create_branch(&self, name: &str) -> Result<()> {
457        let repo = self.repository.lock();
458        let current_commit = repo.head()?.peel_to_commit()?;
459        repo.branch(name, &current_commit, false)?;
460        Ok(())
461    }
462
463    fn blame(&self, path: &Path, content: Rope) -> Result<crate::blame::Blame> {
464        let working_directory = self
465            .repository
466            .lock()
467            .workdir()
468            .with_context(|| format!("failed to get git working directory for file {:?}", path))?
469            .to_path_buf();
470
471        const REMOTE_NAME: &str = "origin";
472        let remote_url = self.remote_url(REMOTE_NAME);
473
474        crate::blame::Blame::for_path(
475            &self.git_binary_path,
476            &working_directory,
477            path,
478            &content,
479            remote_url,
480            self.hosting_provider_registry.clone(),
481        )
482    }
483
484    fn stage_paths(&self, paths: &[RepoPath]) -> Result<()> {
485        let working_directory = self
486            .repository
487            .lock()
488            .workdir()
489            .context("failed to read git work directory")?
490            .to_path_buf();
491
492        if !paths.is_empty() {
493            let output = new_std_command(&self.git_binary_path)
494                .current_dir(&working_directory)
495                .args(["update-index", "--add", "--remove", "--"])
496                .args(paths.iter().map(|p| p.as_ref()))
497                .output()?;
498            if !output.status.success() {
499                return Err(anyhow!(
500                    "Failed to stage paths:\n{}",
501                    String::from_utf8_lossy(&output.stderr)
502                ));
503            }
504        }
505        Ok(())
506    }
507
508    fn unstage_paths(&self, paths: &[RepoPath]) -> Result<()> {
509        let working_directory = self
510            .repository
511            .lock()
512            .workdir()
513            .context("failed to read git work directory")?
514            .to_path_buf();
515
516        if !paths.is_empty() {
517            let output = new_std_command(&self.git_binary_path)
518                .current_dir(&working_directory)
519                .args(["reset", "--quiet", "--"])
520                .args(paths.iter().map(|p| p.as_ref()))
521                .output()?;
522            if !output.status.success() {
523                return Err(anyhow!(
524                    "Failed to unstage:\n{}",
525                    String::from_utf8_lossy(&output.stderr)
526                ));
527            }
528        }
529        Ok(())
530    }
531
532    fn commit(&self, message: &str, name_and_email: Option<(&str, &str)>) -> Result<()> {
533        let working_directory = self
534            .repository
535            .lock()
536            .workdir()
537            .context("failed to read git work directory")?
538            .to_path_buf();
539        let mut args = vec!["commit", "--quiet", "-m", message, "--cleanup=strip"];
540        let author = name_and_email.map(|(name, email)| format!("{name} <{email}>"));
541        if let Some(author) = author.as_deref() {
542            args.push("--author");
543            args.push(author);
544        }
545
546        let output = new_std_command(&self.git_binary_path)
547            .current_dir(&working_directory)
548            .args(args)
549            .output()?;
550
551        if !output.status.success() {
552            return Err(anyhow!(
553                "Failed to commit:\n{}",
554                String::from_utf8_lossy(&output.stderr)
555            ));
556        }
557        Ok(())
558    }
559}
560
561#[derive(Debug, Clone)]
562pub struct FakeGitRepository {
563    state: Arc<Mutex<FakeGitRepositoryState>>,
564}
565
566#[derive(Debug, Clone)]
567pub struct FakeGitRepositoryState {
568    pub path: PathBuf,
569    pub event_emitter: smol::channel::Sender<PathBuf>,
570    pub head_contents: HashMap<RepoPath, String>,
571    pub index_contents: HashMap<RepoPath, String>,
572    pub blames: HashMap<RepoPath, Blame>,
573    pub statuses: HashMap<RepoPath, FileStatus>,
574    pub current_branch_name: Option<String>,
575    pub branches: HashSet<String>,
576}
577
578impl FakeGitRepository {
579    pub fn open(state: Arc<Mutex<FakeGitRepositoryState>>) -> Arc<dyn GitRepository> {
580        Arc::new(FakeGitRepository { state })
581    }
582}
583
584impl FakeGitRepositoryState {
585    pub fn new(path: PathBuf, event_emitter: smol::channel::Sender<PathBuf>) -> Self {
586        FakeGitRepositoryState {
587            path,
588            event_emitter,
589            head_contents: Default::default(),
590            index_contents: Default::default(),
591            blames: Default::default(),
592            statuses: Default::default(),
593            current_branch_name: Default::default(),
594            branches: Default::default(),
595        }
596    }
597}
598
599impl GitRepository for FakeGitRepository {
600    fn reload_index(&self) {}
601
602    fn load_index_text(&self, path: &RepoPath) -> Option<String> {
603        let state = self.state.lock();
604        state.index_contents.get(path.as_ref()).cloned()
605    }
606
607    fn load_committed_text(&self, path: &RepoPath) -> Option<String> {
608        let state = self.state.lock();
609        state.head_contents.get(path.as_ref()).cloned()
610    }
611
612    fn set_index_text(&self, path: &RepoPath, content: Option<String>) -> anyhow::Result<()> {
613        let mut state = self.state.lock();
614        if let Some(content) = content {
615            state.index_contents.insert(path.clone(), content);
616        } else {
617            state.index_contents.remove(path);
618        }
619        state
620            .event_emitter
621            .try_send(state.path.clone())
622            .expect("Dropped repo change event");
623        Ok(())
624    }
625
626    fn remote_url(&self, _name: &str) -> Option<String> {
627        None
628    }
629
630    fn head_sha(&self) -> Option<String> {
631        None
632    }
633
634    fn merge_head_shas(&self) -> Vec<String> {
635        vec![]
636    }
637
638    fn show(&self, _: &str) -> Result<CommitDetails> {
639        unimplemented!()
640    }
641
642    fn reset(&self, _: &str, _: ResetMode) -> Result<()> {
643        unimplemented!()
644    }
645
646    fn checkout_files(&self, _: &str, _: &[RepoPath]) -> Result<()> {
647        unimplemented!()
648    }
649
650    fn path(&self) -> PathBuf {
651        let state = self.state.lock();
652        state.path.clone()
653    }
654
655    fn main_repository_path(&self) -> PathBuf {
656        self.path()
657    }
658
659    fn status(&self, path_prefixes: &[RepoPath]) -> Result<GitStatus> {
660        let state = self.state.lock();
661
662        let mut entries = state
663            .statuses
664            .iter()
665            .filter_map(|(repo_path, status)| {
666                if path_prefixes
667                    .iter()
668                    .any(|path_prefix| repo_path.0.starts_with(path_prefix))
669                {
670                    Some((repo_path.to_owned(), *status))
671                } else {
672                    None
673                }
674            })
675            .collect::<Vec<_>>();
676        entries.sort_unstable_by(|(a, _), (b, _)| a.cmp(&b));
677
678        Ok(GitStatus {
679            entries: entries.into(),
680        })
681    }
682
683    fn branches(&self) -> Result<Vec<Branch>> {
684        let state = self.state.lock();
685        let current_branch = &state.current_branch_name;
686        Ok(state
687            .branches
688            .iter()
689            .map(|branch_name| Branch {
690                is_head: Some(branch_name) == current_branch.as_ref(),
691                name: branch_name.into(),
692                most_recent_commit: None,
693                upstream: None,
694            })
695            .collect())
696    }
697
698    fn branch_exits(&self, name: &str) -> Result<bool> {
699        let state = self.state.lock();
700        Ok(state.branches.contains(name))
701    }
702
703    fn change_branch(&self, name: &str) -> Result<()> {
704        let mut state = self.state.lock();
705        state.current_branch_name = Some(name.to_owned());
706        state
707            .event_emitter
708            .try_send(state.path.clone())
709            .expect("Dropped repo change event");
710        Ok(())
711    }
712
713    fn create_branch(&self, name: &str) -> Result<()> {
714        let mut state = self.state.lock();
715        state.branches.insert(name.to_owned());
716        state
717            .event_emitter
718            .try_send(state.path.clone())
719            .expect("Dropped repo change event");
720        Ok(())
721    }
722
723    fn blame(&self, path: &Path, _content: Rope) -> Result<crate::blame::Blame> {
724        let state = self.state.lock();
725        state
726            .blames
727            .get(path)
728            .with_context(|| format!("failed to get blame for {:?}", path))
729            .cloned()
730    }
731
732    fn stage_paths(&self, _paths: &[RepoPath]) -> Result<()> {
733        unimplemented!()
734    }
735
736    fn unstage_paths(&self, _paths: &[RepoPath]) -> Result<()> {
737        unimplemented!()
738    }
739
740    fn commit(&self, _message: &str, _name_and_email: Option<(&str, &str)>) -> Result<()> {
741        unimplemented!()
742    }
743}
744
745fn check_path_to_repo_path_errors(relative_file_path: &Path) -> Result<()> {
746    match relative_file_path.components().next() {
747        None => anyhow::bail!("repo path should not be empty"),
748        Some(Component::Prefix(_)) => anyhow::bail!(
749            "repo path `{}` should be relative, not a windows prefix",
750            relative_file_path.to_string_lossy()
751        ),
752        Some(Component::RootDir) => {
753            anyhow::bail!(
754                "repo path `{}` should be relative",
755                relative_file_path.to_string_lossy()
756            )
757        }
758        Some(Component::CurDir) => {
759            anyhow::bail!(
760                "repo path `{}` should not start with `.`",
761                relative_file_path.to_string_lossy()
762            )
763        }
764        Some(Component::ParentDir) => {
765            anyhow::bail!(
766                "repo path `{}` should not start with `..`",
767                relative_file_path.to_string_lossy()
768            )
769        }
770        _ => Ok(()),
771    }
772}
773
774pub static WORK_DIRECTORY_REPO_PATH: LazyLock<RepoPath> =
775    LazyLock::new(|| RepoPath(Path::new("").into()));
776
777#[derive(Clone, Debug, Ord, Hash, PartialOrd, Eq, PartialEq)]
778pub struct RepoPath(pub Arc<Path>);
779
780impl RepoPath {
781    pub fn new(path: PathBuf) -> Self {
782        debug_assert!(path.is_relative(), "Repo paths must be relative");
783
784        RepoPath(path.into())
785    }
786
787    pub fn from_str(path: &str) -> Self {
788        let path = Path::new(path);
789        debug_assert!(path.is_relative(), "Repo paths must be relative");
790
791        RepoPath(path.into())
792    }
793}
794
795impl std::fmt::Display for RepoPath {
796    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
797        self.0.to_string_lossy().fmt(f)
798    }
799}
800
801impl From<&Path> for RepoPath {
802    fn from(value: &Path) -> Self {
803        RepoPath::new(value.into())
804    }
805}
806
807impl From<Arc<Path>> for RepoPath {
808    fn from(value: Arc<Path>) -> Self {
809        RepoPath(value)
810    }
811}
812
813impl From<PathBuf> for RepoPath {
814    fn from(value: PathBuf) -> Self {
815        RepoPath::new(value)
816    }
817}
818
819impl From<&str> for RepoPath {
820    fn from(value: &str) -> Self {
821        Self::from_str(value)
822    }
823}
824
825impl Default for RepoPath {
826    fn default() -> Self {
827        RepoPath(Path::new("").into())
828    }
829}
830
831impl AsRef<Path> for RepoPath {
832    fn as_ref(&self) -> &Path {
833        self.0.as_ref()
834    }
835}
836
837impl std::ops::Deref for RepoPath {
838    type Target = Path;
839
840    fn deref(&self) -> &Self::Target {
841        &self.0
842    }
843}
844
845impl Borrow<Path> for RepoPath {
846    fn borrow(&self) -> &Path {
847        self.0.as_ref()
848    }
849}
850
851#[derive(Debug)]
852pub struct RepoPathDescendants<'a>(pub &'a Path);
853
854impl<'a> MapSeekTarget<RepoPath> for RepoPathDescendants<'a> {
855    fn cmp_cursor(&self, key: &RepoPath) -> Ordering {
856        if key.starts_with(self.0) {
857            Ordering::Greater
858        } else {
859            self.0.cmp(key)
860        }
861    }
862}
863
864fn parse_branch_input(input: &str) -> Result<Vec<Branch>> {
865    let mut branches = Vec::new();
866    for line in input.split('\n') {
867        if line.is_empty() {
868            continue;
869        }
870        let mut fields = line.split('\x00');
871        let is_current_branch = fields.next().context("no HEAD")? == "*";
872        let head_sha: SharedString = fields.next().context("no objectname")?.to_string().into();
873        let ref_name: SharedString = fields
874            .next()
875            .context("no refname")?
876            .strip_prefix("refs/heads/")
877            .context("unexpected format for refname")?
878            .to_string()
879            .into();
880        let upstream_name = fields.next().context("no upstream")?.to_string();
881        let upstream_tracking = parse_upstream_track(fields.next().context("no upstream:track")?)?;
882        let commiterdate = fields.next().context("no committerdate")?.parse::<i64>()?;
883        let subject: SharedString = fields
884            .next()
885            .context("no contents:subject")?
886            .to_string()
887            .into();
888
889        branches.push(Branch {
890            is_head: is_current_branch,
891            name: ref_name,
892            most_recent_commit: Some(CommitSummary {
893                sha: head_sha,
894                subject,
895                commit_timestamp: commiterdate,
896            }),
897            upstream: if upstream_name.is_empty() {
898                None
899            } else {
900                Some(Upstream {
901                    ref_name: upstream_name.into(),
902                    tracking: upstream_tracking,
903                })
904            },
905        })
906    }
907
908    Ok(branches)
909}
910
911fn parse_upstream_track(upstream_track: &str) -> Result<Option<UpstreamTracking>> {
912    if upstream_track == "" {
913        return Ok(Some(UpstreamTracking {
914            ahead: 0,
915            behind: 0,
916        }));
917    }
918
919    let upstream_track = upstream_track
920        .strip_prefix("[")
921        .ok_or_else(|| anyhow!("missing ["))?;
922    let upstream_track = upstream_track
923        .strip_suffix("]")
924        .ok_or_else(|| anyhow!("missing ["))?;
925    let mut ahead: u32 = 0;
926    let mut behind: u32 = 0;
927    for component in upstream_track.split(", ") {
928        if component == "gone" {
929            return Ok(None);
930        }
931        if let Some(ahead_num) = component.strip_prefix("ahead ") {
932            ahead = ahead_num.parse::<u32>()?;
933        }
934        if let Some(behind_num) = component.strip_prefix("behind ") {
935            behind = behind_num.parse::<u32>()?;
936        }
937    }
938    Ok(Some(UpstreamTracking { ahead, behind }))
939}
940
941#[test]
942fn test_branches_parsing() {
943    // suppress "help: octal escapes are not supported, `\0` is always null"
944    #[allow(clippy::octal_escapes)]
945    let input = "*\0060964da10574cd9bf06463a53bf6e0769c5c45e\0refs/heads/zed-patches\0refs/remotes/origin/zed-patches\0\01733187470\0generated protobuf\n";
946    assert_eq!(
947        parse_branch_input(&input).unwrap(),
948        vec![Branch {
949            is_head: true,
950            name: "zed-patches".into(),
951            upstream: Some(Upstream {
952                ref_name: "refs/remotes/origin/zed-patches".into(),
953                tracking: Some(UpstreamTracking {
954                    ahead: 0,
955                    behind: 0
956                })
957            }),
958            most_recent_commit: Some(CommitSummary {
959                sha: "060964da10574cd9bf06463a53bf6e0769c5c45e".into(),
960                subject: "generated protobuf".into(),
961                commit_timestamp: 1733187470,
962            })
963        }]
964    )
965}