grit_lib/gitbutler.rs
1//! Recognize a GitButler workspace.
2//!
3//! GitButler applies several branches at once by checking out `gitbutler/workspace`, whose tip
4//! is a merge of every applied branch that GitButler maintains itself. That tip is bookkeeping,
5//! not work: tools that show history or status should show the applied branches instead, and
6//! should not commit on top of it, because GitButler rewrites the workspace branch and would
7//! lose such a commit.
8//!
9//! ```no_run
10//! # fn main() -> grit_lib::error::Result<()> {
11//! use grit_lib::gitbutler::GitButlerWorkspace;
12//! use grit_lib::prelude::{Repository, RepositoryOptions};
13//!
14//! let repo = Repository::discover(None, &RepositoryOptions::empty())?;
15//! if let Some(workspace) = GitButlerWorkspace::detect(&repo)? {
16//! for head in &workspace.heads {
17//! println!("{} {}", head.commit, head.branches.join(", "));
18//! }
19//! }
20//! # Ok(())
21//! # }
22//! ```
23//!
24//! The branch and the commit marker it looks for are constants:
25//!
26//! ```rust
27//! use grit_lib::gitbutler::{WORKSPACE_BRANCH, WORKSPACE_COMMIT_MARKER};
28//!
29//! assert_eq!(WORKSPACE_BRANCH, "refs/heads/gitbutler/workspace");
30//! assert!("GitButler Workspace Commit\n\nbranches: a, b".starts_with(WORKSPACE_COMMIT_MARKER));
31//! ```
32
33use crate::error::Result;
34use crate::objects::ObjectId;
35use crate::refs::list_refs_for_repository;
36use crate::repo::Repository;
37use crate::state::{resolve_head_for_repository, HeadState};
38
39/// The branch GitButler checks out to apply several branches at once.
40pub const WORKSPACE_BRANCH: &str = "refs/heads/gitbutler/workspace";
41
42/// Text GitButler puts in the message of the workspace branch's tip commit.
43pub const WORKSPACE_COMMIT_MARKER: &str = "GitButler Workspace Commit";
44
45/// Local branches under this prefix are GitButler's own, not applied work.
46const GITBUTLER_BRANCH_PREFIX: &str = "refs/heads/gitbutler/";
47
48/// A checked-out GitButler workspace: `HEAD` is on [`WORKSPACE_BRANCH`] and its tip is the
49/// workspace commit ([`WORKSPACE_COMMIT_MARKER`] in its message).
50#[derive(Debug, Clone, PartialEq, Eq)]
51pub struct GitButlerWorkspace {
52 /// The workspace commit at the tip of [`WORKSPACE_BRANCH`].
53 pub commit: ObjectId,
54 /// The heads merged into the workspace commit, in its parent order.
55 pub heads: Vec<WorkspaceHead>,
56}
57
58/// One head merged into a [`GitButlerWorkspace`].
59#[derive(Debug, Clone, PartialEq, Eq)]
60pub struct WorkspaceHead {
61 /// The merged commit (a parent of the workspace commit).
62 pub commit: ObjectId,
63 /// Short names of the local branches that point at [`Self::commit`], sorted, leaving out
64 /// GitButler's own `gitbutler/*` branches. Empty when no branch points there.
65 pub branches: Vec<String>,
66}
67
68impl GitButlerWorkspace {
69 /// The workspace `repo` has checked out, or `None` when `HEAD` is not on one.
70 ///
71 /// `HEAD` must be on [`WORKSPACE_BRANCH`] and that branch's tip must be a commit whose
72 /// message contains [`WORKSPACE_COMMIT_MARKER`]; a `gitbutler/workspace` branch with any
73 /// other tip is treated as an ordinary branch.
74 ///
75 /// # Errors
76 ///
77 /// Returns ref store and object database errors from reading `HEAD`, the tip commit or the
78 /// local branches.
79 pub fn detect(repo: &Repository) -> Result<Option<Self>> {
80 let HeadState::Branch {
81 refname,
82 oid: Some(tip),
83 ..
84 } = resolve_head_for_repository(repo)?
85 else {
86 return Ok(None);
87 };
88 if refname != WORKSPACE_BRANCH {
89 return Ok(None);
90 }
91 let commit = repo.odb().read_commit(&tip)?;
92 if !commit.message.contains(WORKSPACE_COMMIT_MARKER) {
93 return Ok(None);
94 }
95 let branches = list_refs_for_repository(repo, "refs/heads/")?;
96 let heads = commit
97 .parents
98 .iter()
99 .map(|&parent| {
100 let mut names: Vec<String> = branches
101 .iter()
102 .filter(|(name, oid)| {
103 *oid == parent && !name.starts_with(GITBUTLER_BRANCH_PREFIX)
104 })
105 .filter_map(|(name, _)| name.strip_prefix("refs/heads/"))
106 .map(str::to_owned)
107 .collect();
108 names.sort();
109 WorkspaceHead {
110 commit: parent,
111 branches: names,
112 }
113 })
114 .collect();
115 Ok(Some(Self { commit: tip, heads }))
116 }
117
118 /// The merged heads' commits, in parent order: where history of the applied work starts.
119 #[must_use]
120 pub fn head_commits(&self) -> Vec<ObjectId> {
121 self.heads.iter().map(|head| head.commit).collect()
122 }
123}