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