1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
//! Recognize a GitButler workspace.
//!
//! GitButler applies several branches at once by checking out `gitbutler/workspace`, whose tip
//! is a merge of every applied branch that GitButler maintains itself. That tip is bookkeeping,
//! not work: tools that show history or status should show the applied branches instead, and
//! should not commit on top of it, because GitButler rewrites the workspace branch and would
//! lose such a commit.
//!
//! [`GitButlerWorkspace::standing`] turns `HEAD`'s comparison with the target branch into the
//! workspace's view: the ahead count and commits without the workspace commit, and how far
//! each applied head is ahead on its own.
//!
//! ```no_run
//! # fn main() -> grit_lib::error::Result<()> {
//! use grit_lib::gitbutler::GitButlerWorkspace;
//! use grit_lib::prelude::{Repository, RepositoryOptions};
//!
//! let repo = Repository::discover(None, &RepositoryOptions::empty())?;
//! if let Some(workspace) = GitButlerWorkspace::detect(&repo)? {
//! for head in &workspace.heads {
//! println!("{} {}", head.commit, head.branches.join(", "));
//! }
//! }
//! # Ok(())
//! # }
//! ```
//!
//! The branch and the commit marker it looks for are constants:
//!
//! ```rust
//! use grit_lib::gitbutler::{WORKSPACE_BRANCH, WORKSPACE_COMMIT_MARKER};
//!
//! assert_eq!(WORKSPACE_BRANCH, "refs/heads/gitbutler/workspace");
//! assert!("GitButler Workspace Commit\n\nbranches: a, b".starts_with(WORKSPACE_COMMIT_MARKER));
//! ```
use crate::error::Result;
use crate::objects::ObjectId;
use crate::refs::branch_tracking::{TipComparison, TrackedComparison};
use crate::refs::list_refs_for_repository;
use crate::repo::Repository;
use crate::state::{resolve_head_for_repository, HeadState};
/// The branch GitButler checks out to apply several branches at once.
pub const WORKSPACE_BRANCH: &str = "refs/heads/gitbutler/workspace";
/// Text GitButler puts in the message of the workspace branch's tip commit.
pub const WORKSPACE_COMMIT_MARKER: &str = "GitButler Workspace Commit";
/// Local branches under this prefix are GitButler's own, not applied work.
const GITBUTLER_BRANCH_PREFIX: &str = "refs/heads/gitbutler/";
/// A checked-out GitButler workspace: `HEAD` is on [`WORKSPACE_BRANCH`] and its tip is the
/// workspace commit ([`WORKSPACE_COMMIT_MARKER`] in its message).
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct GitButlerWorkspace {
/// The workspace commit at the tip of [`WORKSPACE_BRANCH`].
pub commit: ObjectId,
/// The heads merged into the workspace commit, in its parent order.
pub heads: Vec<WorkspaceHead>,
}
/// One head merged into a [`GitButlerWorkspace`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct WorkspaceHead {
/// The merged commit (a parent of the workspace commit).
pub commit: ObjectId,
/// Short names of the local branches that point at [`Self::commit`], sorted, leaving out
/// GitButler's own `gitbutler/*` branches. Empty when no branch points there.
pub branches: Vec<String>,
}
impl GitButlerWorkspace {
/// The workspace `repo` has checked out, or `None` when `HEAD` is not on one.
///
/// `HEAD` must be on [`WORKSPACE_BRANCH`] and that branch's tip must be a commit whose
/// message contains [`WORKSPACE_COMMIT_MARKER`]; a `gitbutler/workspace` branch with any
/// other tip is treated as an ordinary branch.
///
/// # Errors
///
/// Returns ref store and object database errors from reading `HEAD`, the tip commit or the
/// local branches.
pub fn detect(repo: &Repository) -> Result<Option<Self>> {
let HeadState::Branch {
refname,
oid: Some(tip),
..
} = resolve_head_for_repository(repo)?
else {
return Ok(None);
};
if refname != WORKSPACE_BRANCH {
return Ok(None);
}
let commit = repo.odb().read_commit(&tip)?;
if !commit.message.contains(WORKSPACE_COMMIT_MARKER) {
return Ok(None);
}
let branches = list_refs_for_repository(repo, "refs/heads/")?;
let heads = commit
.parents
.iter()
.map(|&parent| {
let mut names: Vec<String> = branches
.iter()
.filter(|(name, oid)| {
*oid == parent && !name.starts_with(GITBUTLER_BRANCH_PREFIX)
})
.filter_map(|(name, _)| name.strip_prefix("refs/heads/"))
.map(str::to_owned)
.collect();
names.sort();
WorkspaceHead {
commit: parent,
branches: names,
}
})
.collect();
Ok(Some(Self { commit: tip, heads }))
}
/// The merged heads' commits, in parent order: where history of the applied work starts.
#[must_use]
pub fn head_commits(&self) -> Vec<ObjectId> {
self.heads.iter().map(|head| head.commit).collect()
}
/// How the applied work stands against `target`, with the workspace commit left out.
///
/// `target` is the comparison of `HEAD` (the workspace commit) with the target branch, as
/// `BranchStanding::compute` returns it. The workspace commit is bookkeeping, so it is dropped from the ahead count
/// and the newest ahead commits, and each head is compared with the target on its own.
/// When `target` was computed without counts, no head is compared either.
///
/// # Parameters
///
/// - `repo`: the repository the workspace was detected in.
/// - `target`: `HEAD`'s comparison with the target branch.
///
/// # Errors
///
/// Propagates object read and parse errors from comparing a head with the target.
pub fn standing(
&self,
repo: &Repository,
target: &TrackedComparison,
) -> Result<WorkspaceStanding> {
let heads_ahead = match target.ahead {
Some(_) => self
.heads
.iter()
.map(|head| {
TipComparison::compute(repo, head.commit, target.oid, 0)
.map(|comparison| Some(comparison.ahead))
})
.collect::<Result<_>>()?,
None => vec![None; self.heads.len()],
};
Ok(WorkspaceStanding {
ahead: target.ahead.map(|n| n.saturating_sub(1)),
newest_ahead: target
.newest_ahead
.iter()
.copied()
.filter(|&oid| oid != self.commit)
.collect(),
heads_ahead,
})
}
}
/// A [`GitButlerWorkspace`]'s standing against the target branch, from
/// [`GitButlerWorkspace::standing`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct WorkspaceStanding {
/// Commits of the applied work the target lacks, not counting the workspace commit; `None`
/// when the target comparison was made without counts.
pub ahead: Option<usize>,
/// The newest of those commits, newest first: the target comparison's newest ahead commits
/// without the workspace commit.
pub newest_ahead: Vec<ObjectId>,
/// For each of [`GitButlerWorkspace::heads`], in order, how many of its commits the target
/// lacks; every entry is `None` when the target comparison was made without counts.
pub heads_ahead: Vec<Option<usize>>,
}
#[cfg(test)]
mod tests {
use super::*;
use crate::environment::RepositoryOptions;
use crate::objects::ObjectKind;
use crate::refs::{write_ref, write_symbolic_ref};
use crate::repo::InitOptions;
use tempfile::TempDir;
fn write_commit(repo: &Repository, parents: &[ObjectId], msg: &str) -> Result<ObjectId> {
let odb = repo.odb();
let tree = odb.write_loose_materialize(ObjectKind::Tree, b"")?;
let mut body = format!("tree {tree}\n");
for parent in parents {
body.push_str(&format!("parent {parent}\n"));
}
body.push_str(&format!(
"author T <t@e.com> 100 +0000\ncommitter T <t@e.com> 100 +0000\n\n{msg}\n"
));
odb.write_loose_materialize(ObjectKind::Commit, body.as_bytes())
}
/// `main` at `base`, `one` two commits past it, `two` one commit past it, and the workspace
/// merging both checked out.
fn workspace_repo(dir: &TempDir) -> Result<(Repository, ObjectId, ObjectId)> {
Repository::init(
dir.path(),
&InitOptions {
bare: true,
..InitOptions::default()
},
)?;
let repo = Repository::open(dir.path(), &RepositoryOptions::empty())?;
let base = write_commit(&repo, &[], "base")?;
let one_a = write_commit(&repo, &[base], "one a")?;
let one = write_commit(&repo, &[one_a], "one b")?;
let two = write_commit(&repo, &[base], "two")?;
let workspace = write_commit(&repo, &[one, two], WORKSPACE_COMMIT_MARKER)?;
for (name, oid) in [("main", base), ("one", one), ("two", two)] {
write_ref(repo.git_dir(), &format!("refs/heads/{name}"), &oid)?;
}
write_ref(repo.git_dir(), WORKSPACE_BRANCH, &workspace)?;
write_symbolic_ref(repo.git_dir(), "HEAD", WORKSPACE_BRANCH)?;
Ok((repo, base, workspace))
}
fn comparison(
repo: &Repository,
head: ObjectId,
target: ObjectId,
counts: bool,
) -> Result<TrackedComparison> {
let tip = TipComparison::compute(repo, head, target, 10)?;
Ok(TrackedComparison {
name: "main".to_owned(),
full_ref: "refs/heads/main".to_owned(),
oid: target,
ahead: counts.then_some(tip.ahead),
behind: counts.then_some(tip.behind),
newest_ahead: if counts { tip.newest_ahead } else { Vec::new() },
})
}
#[test]
fn standing_drops_the_workspace_commit_and_counts_each_head() -> Result<()> {
let dir = TempDir::new().map_err(crate::error::Error::Io)?;
let (repo, main, commit) = workspace_repo(&dir)?;
let workspace = GitButlerWorkspace::detect(&repo)?.expect("a workspace");
let target = comparison(&repo, commit, main, true)?;
assert_eq!(target.ahead, Some(4));
let standing = workspace.standing(&repo, &target)?;
assert_eq!(standing.ahead, Some(3));
assert_eq!(standing.newest_ahead.len(), 3);
assert!(!standing.newest_ahead.contains(&commit));
assert_eq!(standing.heads_ahead, [Some(2), Some(1)]);
Ok(())
}
#[test]
fn standing_without_counts_compares_no_head() -> Result<()> {
let dir = TempDir::new().map_err(crate::error::Error::Io)?;
let (repo, main, commit) = workspace_repo(&dir)?;
let workspace = GitButlerWorkspace::detect(&repo)?.expect("a workspace");
let standing = workspace.standing(&repo, &comparison(&repo, commit, main, false)?)?;
assert_eq!(
standing,
WorkspaceStanding {
ahead: None,
newest_ahead: Vec::new(),
heads_ahead: vec![None, None],
}
);
Ok(())
}
#[test]
fn standing_against_a_target_holding_the_workspace_counts_nothing_ahead() -> Result<()> {
let dir = TempDir::new().map_err(crate::error::Error::Io)?;
let (repo, _, commit) = workspace_repo(&dir)?;
let workspace = GitButlerWorkspace::detect(&repo)?.expect("a workspace");
let standing = workspace.standing(&repo, &comparison(&repo, commit, commit, true)?)?;
assert_eq!(standing.ahead, Some(0));
assert!(standing.newest_ahead.is_empty());
assert_eq!(standing.heads_ahead, [Some(0), Some(0)]);
Ok(())
}
}