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    /// Also returns `None` for symlinks.
 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    /// Also returns `None` for symlinks.
 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 entry = head.get_path(path).ok()?;
290        if entry.filemode() == i32::from(git2::FileMode::Link) {
291            return None;
292        }
293        let content = repo.find_blob(entry.id()).log_err()?.content().to_owned();
294        let content = String::from_utf8(content).log_err()?;
295        Some(content)
296    }
297
298    fn set_index_text(&self, path: &RepoPath, content: Option<String>) -> anyhow::Result<()> {
299        let working_directory = self
300            .repository
301            .lock()
302            .workdir()
303            .context("failed to read git work directory")?
304            .to_path_buf();
305        if let Some(content) = content {
306            let mut child = new_std_command(&self.git_binary_path)
307                .current_dir(&working_directory)
308                .args(["hash-object", "-w", "--stdin"])
309                .stdin(Stdio::piped())
310                .stdout(Stdio::piped())
311                .spawn()?;
312            child.stdin.take().unwrap().write_all(content.as_bytes())?;
313            let output = child.wait_with_output()?.stdout;
314            let sha = String::from_utf8(output)?;
315
316            log::debug!("indexing SHA: {sha}, path {path:?}");
317
318            let status = new_std_command(&self.git_binary_path)
319                .current_dir(&working_directory)
320                .args(["update-index", "--add", "--cacheinfo", "100644", &sha])
321                .arg(path.as_ref())
322                .status()?;
323
324            if !status.success() {
325                return Err(anyhow!("Failed to add to index: {status:?}"));
326            }
327        } else {
328            let status = new_std_command(&self.git_binary_path)
329                .current_dir(&working_directory)
330                .args(["update-index", "--force-remove"])
331                .arg(path.as_ref())
332                .status()?;
333
334            if !status.success() {
335                return Err(anyhow!("Failed to remove from index: {status:?}"));
336            }
337        }
338
339        Ok(())
340    }
341
342    fn remote_url(&self, name: &str) -> Option<String> {
343        let repo = self.repository.lock();
344        let remote = repo.find_remote(name).ok()?;
345        remote.url().map(|url| url.to_string())
346    }
347
348    fn head_sha(&self) -> Option<String> {
349        Some(self.repository.lock().head().ok()?.target()?.to_string())
350    }
351
352    fn merge_head_shas(&self) -> Vec<String> {
353        let mut shas = Vec::default();
354        self.repository
355            .lock()
356            .mergehead_foreach(|oid| {
357                shas.push(oid.to_string());
358                true
359            })
360            .ok();
361        shas
362    }
363
364    fn status(&self, path_prefixes: &[RepoPath]) -> Result<GitStatus> {
365        let working_directory = self
366            .repository
367            .lock()
368            .workdir()
369            .context("failed to read git work directory")?
370            .to_path_buf();
371        GitStatus::new(&self.git_binary_path, &working_directory, path_prefixes)
372    }
373
374    fn branch_exits(&self, name: &str) -> Result<bool> {
375        let repo = self.repository.lock();
376        let branch = repo.find_branch(name, BranchType::Local);
377        match branch {
378            Ok(_) => Ok(true),
379            Err(e) => match e.code() {
380                git2::ErrorCode::NotFound => Ok(false),
381                _ => Err(anyhow!(e)),
382            },
383        }
384    }
385
386    fn branches(&self) -> Result<Vec<Branch>> {
387        let working_directory = self
388            .repository
389            .lock()
390            .workdir()
391            .context("failed to read git work directory")?
392            .to_path_buf();
393        let fields = [
394            "%(HEAD)",
395            "%(objectname)",
396            "%(refname)",
397            "%(upstream)",
398            "%(upstream:track)",
399            "%(committerdate:unix)",
400            "%(contents:subject)",
401        ]
402        .join("%00");
403        let args = vec!["for-each-ref", "refs/heads/**/*", "--format", &fields];
404
405        let output = new_std_command(&self.git_binary_path)
406            .current_dir(&working_directory)
407            .args(args)
408            .output()?;
409
410        if !output.status.success() {
411            return Err(anyhow!(
412                "Failed to git git branches:\n{}",
413                String::from_utf8_lossy(&output.stderr)
414            ));
415        }
416
417        let input = String::from_utf8_lossy(&output.stdout);
418
419        let mut branches = parse_branch_input(&input)?;
420        if branches.is_empty() {
421            let args = vec!["symbolic-ref", "--quiet", "--short", "HEAD"];
422
423            let output = new_std_command(&self.git_binary_path)
424                .current_dir(&working_directory)
425                .args(args)
426                .output()?;
427
428            // git symbolic-ref returns a non-0 exit code if HEAD points
429            // to something other than a branch
430            if output.status.success() {
431                let name = String::from_utf8_lossy(&output.stdout).trim().to_string();
432
433                branches.push(Branch {
434                    name: name.into(),
435                    is_head: true,
436                    upstream: None,
437                    most_recent_commit: None,
438                });
439            }
440        }
441
442        Ok(branches)
443    }
444
445    fn change_branch(&self, name: &str) -> Result<()> {
446        let repo = self.repository.lock();
447        let revision = repo.find_branch(name, BranchType::Local)?;
448        let revision = revision.get();
449        let as_tree = revision.peel_to_tree()?;
450        repo.checkout_tree(as_tree.as_object(), None)?;
451        repo.set_head(
452            revision
453                .name()
454                .ok_or_else(|| anyhow!("Branch name could not be retrieved"))?,
455        )?;
456        Ok(())
457    }
458
459    fn create_branch(&self, name: &str) -> Result<()> {
460        let repo = self.repository.lock();
461        let current_commit = repo.head()?.peel_to_commit()?;
462        repo.branch(name, &current_commit, false)?;
463        Ok(())
464    }
465
466    fn blame(&self, path: &Path, content: Rope) -> Result<crate::blame::Blame> {
467        let working_directory = self
468            .repository
469            .lock()
470            .workdir()
471            .with_context(|| format!("failed to get git working directory for file {:?}", path))?
472            .to_path_buf();
473
474        const REMOTE_NAME: &str = "origin";
475        let remote_url = self.remote_url(REMOTE_NAME);
476
477        crate::blame::Blame::for_path(
478            &self.git_binary_path,
479            &working_directory,
480            path,
481            &content,
482            remote_url,
483            self.hosting_provider_registry.clone(),
484        )
485    }
486
487    fn stage_paths(&self, paths: &[RepoPath]) -> Result<()> {
488        let working_directory = self
489            .repository
490            .lock()
491            .workdir()
492            .context("failed to read git work directory")?
493            .to_path_buf();
494
495        if !paths.is_empty() {
496            let output = new_std_command(&self.git_binary_path)
497                .current_dir(&working_directory)
498                .args(["update-index", "--add", "--remove", "--"])
499                .args(paths.iter().map(|p| p.as_ref()))
500                .output()?;
501            if !output.status.success() {
502                return Err(anyhow!(
503                    "Failed to stage paths:\n{}",
504                    String::from_utf8_lossy(&output.stderr)
505                ));
506            }
507        }
508        Ok(())
509    }
510
511    fn unstage_paths(&self, paths: &[RepoPath]) -> Result<()> {
512        let working_directory = self
513            .repository
514            .lock()
515            .workdir()
516            .context("failed to read git work directory")?
517            .to_path_buf();
518
519        if !paths.is_empty() {
520            let output = new_std_command(&self.git_binary_path)
521                .current_dir(&working_directory)
522                .args(["reset", "--quiet", "--"])
523                .args(paths.iter().map(|p| p.as_ref()))
524                .output()?;
525            if !output.status.success() {
526                return Err(anyhow!(
527                    "Failed to unstage:\n{}",
528                    String::from_utf8_lossy(&output.stderr)
529                ));
530            }
531        }
532        Ok(())
533    }
534
535    fn commit(&self, message: &str, name_and_email: Option<(&str, &str)>) -> Result<()> {
536        let working_directory = self
537            .repository
538            .lock()
539            .workdir()
540            .context("failed to read git work directory")?
541            .to_path_buf();
542        let mut args = vec!["commit", "--quiet", "-m", message, "--cleanup=strip"];
543        let author = name_and_email.map(|(name, email)| format!("{name} <{email}>"));
544        if let Some(author) = author.as_deref() {
545            args.push("--author");
546            args.push(author);
547        }
548
549        let output = new_std_command(&self.git_binary_path)
550            .current_dir(&working_directory)
551            .args(args)
552            .output()?;
553
554        if !output.status.success() {
555            return Err(anyhow!(
556                "Failed to commit:\n{}",
557                String::from_utf8_lossy(&output.stderr)
558            ));
559        }
560        Ok(())
561    }
562}
563
564#[derive(Debug, Clone)]
565pub struct FakeGitRepository {
566    state: Arc<Mutex<FakeGitRepositoryState>>,
567}
568
569#[derive(Debug, Clone)]
570pub struct FakeGitRepositoryState {
571    pub path: PathBuf,
572    pub event_emitter: smol::channel::Sender<PathBuf>,
573    pub head_contents: HashMap<RepoPath, String>,
574    pub index_contents: HashMap<RepoPath, String>,
575    pub blames: HashMap<RepoPath, Blame>,
576    pub statuses: HashMap<RepoPath, FileStatus>,
577    pub current_branch_name: Option<String>,
578    pub branches: HashSet<String>,
579}
580
581impl FakeGitRepository {
582    pub fn open(state: Arc<Mutex<FakeGitRepositoryState>>) -> Arc<dyn GitRepository> {
583        Arc::new(FakeGitRepository { state })
584    }
585}
586
587impl FakeGitRepositoryState {
588    pub fn new(path: PathBuf, event_emitter: smol::channel::Sender<PathBuf>) -> Self {
589        FakeGitRepositoryState {
590            path,
591            event_emitter,
592            head_contents: Default::default(),
593            index_contents: Default::default(),
594            blames: Default::default(),
595            statuses: Default::default(),
596            current_branch_name: Default::default(),
597            branches: Default::default(),
598        }
599    }
600}
601
602impl GitRepository for FakeGitRepository {
603    fn reload_index(&self) {}
604
605    fn load_index_text(&self, path: &RepoPath) -> Option<String> {
606        let state = self.state.lock();
607        state.index_contents.get(path.as_ref()).cloned()
608    }
609
610    fn load_committed_text(&self, path: &RepoPath) -> Option<String> {
611        let state = self.state.lock();
612        state.head_contents.get(path.as_ref()).cloned()
613    }
614
615    fn set_index_text(&self, path: &RepoPath, content: Option<String>) -> anyhow::Result<()> {
616        let mut state = self.state.lock();
617        if let Some(content) = content {
618            state.index_contents.insert(path.clone(), content);
619        } else {
620            state.index_contents.remove(path);
621        }
622        state
623            .event_emitter
624            .try_send(state.path.clone())
625            .expect("Dropped repo change event");
626        Ok(())
627    }
628
629    fn remote_url(&self, _name: &str) -> Option<String> {
630        None
631    }
632
633    fn head_sha(&self) -> Option<String> {
634        None
635    }
636
637    fn merge_head_shas(&self) -> Vec<String> {
638        vec![]
639    }
640
641    fn show(&self, _: &str) -> Result<CommitDetails> {
642        unimplemented!()
643    }
644
645    fn reset(&self, _: &str, _: ResetMode) -> Result<()> {
646        unimplemented!()
647    }
648
649    fn checkout_files(&self, _: &str, _: &[RepoPath]) -> Result<()> {
650        unimplemented!()
651    }
652
653    fn path(&self) -> PathBuf {
654        let state = self.state.lock();
655        state.path.clone()
656    }
657
658    fn main_repository_path(&self) -> PathBuf {
659        self.path()
660    }
661
662    fn status(&self, path_prefixes: &[RepoPath]) -> Result<GitStatus> {
663        let state = self.state.lock();
664
665        let mut entries = state
666            .statuses
667            .iter()
668            .filter_map(|(repo_path, status)| {
669                if path_prefixes
670                    .iter()
671                    .any(|path_prefix| repo_path.0.starts_with(path_prefix))
672                {
673                    Some((repo_path.to_owned(), *status))
674                } else {
675                    None
676                }
677            })
678            .collect::<Vec<_>>();
679        entries.sort_unstable_by(|(a, _), (b, _)| a.cmp(&b));
680
681        Ok(GitStatus {
682            entries: entries.into(),
683        })
684    }
685
686    fn branches(&self) -> Result<Vec<Branch>> {
687        let state = self.state.lock();
688        let current_branch = &state.current_branch_name;
689        Ok(state
690            .branches
691            .iter()
692            .map(|branch_name| Branch {
693                is_head: Some(branch_name) == current_branch.as_ref(),
694                name: branch_name.into(),
695                most_recent_commit: None,
696                upstream: None,
697            })
698            .collect())
699    }
700
701    fn branch_exits(&self, name: &str) -> Result<bool> {
702        let state = self.state.lock();
703        Ok(state.branches.contains(name))
704    }
705
706    fn change_branch(&self, name: &str) -> Result<()> {
707        let mut state = self.state.lock();
708        state.current_branch_name = Some(name.to_owned());
709        state
710            .event_emitter
711            .try_send(state.path.clone())
712            .expect("Dropped repo change event");
713        Ok(())
714    }
715
716    fn create_branch(&self, name: &str) -> Result<()> {
717        let mut state = self.state.lock();
718        state.branches.insert(name.to_owned());
719        state
720            .event_emitter
721            .try_send(state.path.clone())
722            .expect("Dropped repo change event");
723        Ok(())
724    }
725
726    fn blame(&self, path: &Path, _content: Rope) -> Result<crate::blame::Blame> {
727        let state = self.state.lock();
728        state
729            .blames
730            .get(path)
731            .with_context(|| format!("failed to get blame for {:?}", path))
732            .cloned()
733    }
734
735    fn stage_paths(&self, _paths: &[RepoPath]) -> Result<()> {
736        unimplemented!()
737    }
738
739    fn unstage_paths(&self, _paths: &[RepoPath]) -> Result<()> {
740        unimplemented!()
741    }
742
743    fn commit(&self, _message: &str, _name_and_email: Option<(&str, &str)>) -> Result<()> {
744        unimplemented!()
745    }
746}
747
748fn check_path_to_repo_path_errors(relative_file_path: &Path) -> Result<()> {
749    match relative_file_path.components().next() {
750        None => anyhow::bail!("repo path should not be empty"),
751        Some(Component::Prefix(_)) => anyhow::bail!(
752            "repo path `{}` should be relative, not a windows prefix",
753            relative_file_path.to_string_lossy()
754        ),
755        Some(Component::RootDir) => {
756            anyhow::bail!(
757                "repo path `{}` should be relative",
758                relative_file_path.to_string_lossy()
759            )
760        }
761        Some(Component::CurDir) => {
762            anyhow::bail!(
763                "repo path `{}` should not start with `.`",
764                relative_file_path.to_string_lossy()
765            )
766        }
767        Some(Component::ParentDir) => {
768            anyhow::bail!(
769                "repo path `{}` should not start with `..`",
770                relative_file_path.to_string_lossy()
771            )
772        }
773        _ => Ok(()),
774    }
775}
776
777pub static WORK_DIRECTORY_REPO_PATH: LazyLock<RepoPath> =
778    LazyLock::new(|| RepoPath(Path::new("").into()));
779
780#[derive(Clone, Debug, Ord, Hash, PartialOrd, Eq, PartialEq)]
781pub struct RepoPath(pub Arc<Path>);
782
783impl RepoPath {
784    pub fn new(path: PathBuf) -> Self {
785        debug_assert!(path.is_relative(), "Repo paths must be relative");
786
787        RepoPath(path.into())
788    }
789
790    pub fn from_str(path: &str) -> Self {
791        let path = Path::new(path);
792        debug_assert!(path.is_relative(), "Repo paths must be relative");
793
794        RepoPath(path.into())
795    }
796}
797
798impl std::fmt::Display for RepoPath {
799    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
800        self.0.to_string_lossy().fmt(f)
801    }
802}
803
804impl From<&Path> for RepoPath {
805    fn from(value: &Path) -> Self {
806        RepoPath::new(value.into())
807    }
808}
809
810impl From<Arc<Path>> for RepoPath {
811    fn from(value: Arc<Path>) -> Self {
812        RepoPath(value)
813    }
814}
815
816impl From<PathBuf> for RepoPath {
817    fn from(value: PathBuf) -> Self {
818        RepoPath::new(value)
819    }
820}
821
822impl From<&str> for RepoPath {
823    fn from(value: &str) -> Self {
824        Self::from_str(value)
825    }
826}
827
828impl Default for RepoPath {
829    fn default() -> Self {
830        RepoPath(Path::new("").into())
831    }
832}
833
834impl AsRef<Path> for RepoPath {
835    fn as_ref(&self) -> &Path {
836        self.0.as_ref()
837    }
838}
839
840impl std::ops::Deref for RepoPath {
841    type Target = Path;
842
843    fn deref(&self) -> &Self::Target {
844        &self.0
845    }
846}
847
848impl Borrow<Path> for RepoPath {
849    fn borrow(&self) -> &Path {
850        self.0.as_ref()
851    }
852}
853
854#[derive(Debug)]
855pub struct RepoPathDescendants<'a>(pub &'a Path);
856
857impl<'a> MapSeekTarget<RepoPath> for RepoPathDescendants<'a> {
858    fn cmp_cursor(&self, key: &RepoPath) -> Ordering {
859        if key.starts_with(self.0) {
860            Ordering::Greater
861        } else {
862            self.0.cmp(key)
863        }
864    }
865}
866
867fn parse_branch_input(input: &str) -> Result<Vec<Branch>> {
868    let mut branches = Vec::new();
869    for line in input.split('\n') {
870        if line.is_empty() {
871            continue;
872        }
873        let mut fields = line.split('\x00');
874        let is_current_branch = fields.next().context("no HEAD")? == "*";
875        let head_sha: SharedString = fields.next().context("no objectname")?.to_string().into();
876        let ref_name: SharedString = fields
877            .next()
878            .context("no refname")?
879            .strip_prefix("refs/heads/")
880            .context("unexpected format for refname")?
881            .to_string()
882            .into();
883        let upstream_name = fields.next().context("no upstream")?.to_string();
884        let upstream_tracking = parse_upstream_track(fields.next().context("no upstream:track")?)?;
885        let commiterdate = fields.next().context("no committerdate")?.parse::<i64>()?;
886        let subject: SharedString = fields
887            .next()
888            .context("no contents:subject")?
889            .to_string()
890            .into();
891
892        branches.push(Branch {
893            is_head: is_current_branch,
894            name: ref_name,
895            most_recent_commit: Some(CommitSummary {
896                sha: head_sha,
897                subject,
898                commit_timestamp: commiterdate,
899            }),
900            upstream: if upstream_name.is_empty() {
901                None
902            } else {
903                Some(Upstream {
904                    ref_name: upstream_name.into(),
905                    tracking: upstream_tracking,
906                })
907            },
908        })
909    }
910
911    Ok(branches)
912}
913
914fn parse_upstream_track(upstream_track: &str) -> Result<Option<UpstreamTracking>> {
915    if upstream_track == "" {
916        return Ok(Some(UpstreamTracking {
917            ahead: 0,
918            behind: 0,
919        }));
920    }
921
922    let upstream_track = upstream_track
923        .strip_prefix("[")
924        .ok_or_else(|| anyhow!("missing ["))?;
925    let upstream_track = upstream_track
926        .strip_suffix("]")
927        .ok_or_else(|| anyhow!("missing ["))?;
928    let mut ahead: u32 = 0;
929    let mut behind: u32 = 0;
930    for component in upstream_track.split(", ") {
931        if component == "gone" {
932            return Ok(None);
933        }
934        if let Some(ahead_num) = component.strip_prefix("ahead ") {
935            ahead = ahead_num.parse::<u32>()?;
936        }
937        if let Some(behind_num) = component.strip_prefix("behind ") {
938            behind = behind_num.parse::<u32>()?;
939        }
940    }
941    Ok(Some(UpstreamTracking { ahead, behind }))
942}
943
944#[test]
945fn test_branches_parsing() {
946    // suppress "help: octal escapes are not supported, `\0` is always null"
947    #[allow(clippy::octal_escapes)]
948    let input = "*\0060964da10574cd9bf06463a53bf6e0769c5c45e\0refs/heads/zed-patches\0refs/remotes/origin/zed-patches\0\01733187470\0generated protobuf\n";
949    assert_eq!(
950        parse_branch_input(&input).unwrap(),
951        vec![Branch {
952            is_head: true,
953            name: "zed-patches".into(),
954            upstream: Some(Upstream {
955                ref_name: "refs/remotes/origin/zed-patches".into(),
956                tracking: Some(UpstreamTracking {
957                    ahead: 0,
958                    behind: 0
959                })
960            }),
961            most_recent_commit: Some(CommitSummary {
962                sha: "060964da10574cd9bf06463a53bf6e0769c5c45e".into(),
963                subject: "generated protobuf".into(),
964                commit_timestamp: 1733187470,
965            })
966        }]
967    )
968}