Skip to content

Commit 40e135b

Browse files
committed
Implement snapshot::create_tree() with worktree snapshot supports
1 parent 47a48a8 commit 40e135b

File tree

9 files changed

+306
-98
lines changed

9 files changed

+306
-98
lines changed

crates/but-core/src/lib.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -332,9 +332,9 @@ pub enum IgnoredWorktreeTreeChangeStatus {
332332
pub struct IgnoredWorktreeChange {
333333
/// The worktree-relative path to the change.
334334
#[serde(serialize_with = "gitbutler_serde::bstring_lossy::serialize")]
335-
path: BString,
335+
pub path: BString,
336336
/// The status that caused this change to be ignored.
337-
status: IgnoredWorktreeTreeChangeStatus,
337+
pub status: IgnoredWorktreeTreeChangeStatus,
338338
}
339339

340340
/// The type returned by [`worktree_changes()`](diff::worktree_changes).

crates/but-workspace/src/commit_engine/mod.rs

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -231,11 +231,28 @@ pub fn create_commit(
231231
bail!("cannot currently handle more than 1 parent")
232232
}
233233

234+
let target_tree = match &destination {
235+
Destination::NewCommit {
236+
parent_commit_id: None,
237+
..
238+
} => gix::ObjectId::empty_tree(repo.object_hash()),
239+
Destination::NewCommit {
240+
parent_commit_id: Some(base_commit),
241+
..
242+
}
243+
| Destination::AmendCommit {
244+
commit_id: base_commit,
245+
..
246+
} => but_core::Commit::from_id(base_commit.attach(repo))?
247+
.tree_id_or_auto_resolution()?
248+
.detach(),
249+
};
250+
234251
let CreateTreeOutcome {
235252
rejected_specs,
236253
destination_tree,
237254
changed_tree_pre_cherry_pick,
238-
} = create_tree(repo, &destination, move_source, changes, context_lines)?;
255+
} = create_tree(repo, target_tree, move_source, changes, context_lines)?;
239256
let new_commit = if let Some(new_tree) = destination_tree {
240257
match destination {
241258
Destination::NewCommit {

crates/but-workspace/src/commit_engine/tree/mod.rs

Lines changed: 5 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
use crate::commit_engine::{Destination, MoveSourceCommit, RejectionReason, apply_hunks};
1+
use crate::commit_engine::{MoveSourceCommit, RejectionReason, apply_hunks};
22
use crate::{DiffSpec, HunkHeader};
33
use bstr::{BStr, ByteSlice};
44
use but_core::{RepositoryExt, UnifiedDiff};
@@ -17,8 +17,8 @@ pub struct CreateTreeOutcome {
1717
/// when merging the workspace commit, or because the specified hunks didn't match exactly due to changes
1818
/// that happened in the meantime, or if a file without a change was specified.
1919
pub rejected_specs: Vec<(RejectionReason, DiffSpec)>,
20-
/// The newly created seen from tree that acts as the destination of the changes, or `None` if no commit could be
21-
/// created as all changes-requests were rejected.
20+
/// The newly created seen from tree that acts as the destination of the changes, or `None` if no tree could be
21+
/// created as all changes-requests were rejected (or there was no change).
2222
pub destination_tree: Option<gix::ObjectId>,
2323
/// If `destination_tree` is `Some(_)`, this field is `Some(_)` as well and denotes the base-tree + all changes.
2424
/// If the applied changes were from the worktree, it's `HEAD^{tree}` + changes.
@@ -29,28 +29,11 @@ pub struct CreateTreeOutcome {
2929
/// Like [`create_commit()`], but lower-level and only returns a new tree, without finally associating it with a commit.
3030
pub fn create_tree(
3131
repo: &gix::Repository,
32-
destination: &Destination,
32+
target_tree: gix::ObjectId,
3333
move_source: Option<MoveSourceCommit>,
3434
changes: Vec<DiffSpec>,
3535
context_lines: u32,
3636
) -> anyhow::Result<CreateTreeOutcome> {
37-
let target_tree = match destination {
38-
Destination::NewCommit {
39-
parent_commit_id: None,
40-
..
41-
} => gix::ObjectId::empty_tree(repo.object_hash()),
42-
Destination::NewCommit {
43-
parent_commit_id: Some(base_commit),
44-
..
45-
}
46-
| Destination::AmendCommit {
47-
commit_id: base_commit,
48-
..
49-
} => but_core::Commit::from_id(base_commit.attach(repo))?
50-
.tree_id_or_auto_resolution()?
51-
.detach(),
52-
};
53-
5437
let mut changes: Vec<_> = changes.into_iter().map(Ok).collect();
5538
let (new_tree, changed_tree_pre_cherry_pick) = if changes.is_empty() {
5639
(Some(target_tree), None)
@@ -81,7 +64,7 @@ pub fn create_tree(
8164
let tree_with_changes = if new_tree == actual_base_tree
8265
&& changes.iter().all(|c| {
8366
c.is_ok()
84-
// Some rejections are OK and we want to create a commit anyway.
67+
// Some rejections are OK, and we want to create a commit anyway.
8568
|| !matches!(
8669
c,
8770
Err((RejectionReason::CherryPickMergeConflict,_))

crates/but-workspace/src/snapshot.rs

Lines changed: 9 additions & 69 deletions
Original file line numberDiff line numberDiff line change
@@ -1,66 +1,7 @@
11
//! The ability to create a Git representation of diverse 'state' that can be restored at a later time.
22
3-
///
4-
pub mod create_tree {
5-
use bstr::BString;
6-
7-
/// A way to determine what should be included in the snapshot when calling [create_tree()](function::create_tree).
8-
pub struct State<'a> {
9-
/// The result of a previous worktree changes call.
10-
///
11-
/// It contains detailed information about the complete set of possible changes to become part of the worktree.
12-
pub changes: &'a but_core::WorktreeChanges,
13-
/// Repository-relative and slash-separated paths that match any change in the [`changes`](State::changes) field.
14-
/// **It's an error if there is no match.** as there is not supposed to be a snapshot without a change to the working tree.
15-
pub selection: Vec<BString>,
16-
/// If `true`, store the current `HEAD` reference, i.e. its target, as well as the targets of all refs it's pointing to by symbolic link.
17-
pub head: bool,
18-
}
19-
20-
/// Contains all state that the snapshot contains.
21-
#[derive(Debug, Copy, Clone)]
22-
pub struct Outcome {
23-
/// The snapshot itself, with all the subtrees available that are also listed in this structure.
24-
pub snapshot_tree: gix::ObjectId,
25-
/// For good measure, the input `HEAD^{tree}` that is used as the basis to learn about worktree changes.
26-
pub head_tree: gix::ObjectId,
27-
/// The `head_tree` with the selected worktree changes applied, suitable for being stored in a commit.
28-
pub wortree: gix::ObjectId,
29-
/// The tree representing the current changed index, without conflicts, or `None` if there was no change to the index.
30-
pub index: Option<gix::ObjectId>,
31-
/// A tree with files in a custom storage format to allow keeping conflicting blobs reachable, along with detailed conflict information
32-
/// to allow restoring the conflict entries in the index.
33-
pub index_conflicts: Option<gix::ObjectId>,
34-
/// The tree representing the reference targets of all references within the *workspace*.
35-
pub workspace_references: Option<gix::ObjectId>,
36-
/// The tree representing the reference targets of all references reachable from `HEAD`, so typically `HEAD` itself, and the
37-
/// target object of the reference it is pointing to.
38-
pub head_references: Option<gix::ObjectId>,
39-
/// The tree representing the metadata of all references within the *workspace*.
40-
pub metadata: Option<gix::ObjectId>,
41-
}
42-
43-
pub(super) mod function {
44-
use super::{Outcome, State};
45-
use but_core::RefMetadata;
46-
/// Create a tree that represents the snapshot for the given `selection`, with the basis for everything
47-
/// being the `head_tree_id` (i.e. the tree to which `HEAD` is ultimately pointing to).
48-
///
49-
/// If `workspace_and_meta` is not `None`, the workspace and metadata to store in the snapshot.
50-
/// We will only store reference positions, and assume that their commits are safely stored in the reflog to not
51-
/// be garbage collected. Metadata is only stored for the references that are included in the `workspace`.
52-
///
53-
/// Note that objects will be written into the repository behind `head_tree_id` unless it's configured
54-
/// to keep everything in memory.
55-
pub fn create_tree(
56-
_head_tree_id: gix::Id<'_>,
57-
_selection: State,
58-
_workspace_and_meta: Option<(&but_graph::projection::Workspace, &impl RefMetadata)>,
59-
) -> anyhow::Result<Outcome> {
60-
todo!()
61-
}
62-
}
63-
}
3+
/// Structures to call the [create_tree()] function.
4+
pub mod create_tree;
645
pub use create_tree::function::create_tree;
656

667
/// Utilities related to resolving previously created snapshots.
@@ -91,18 +32,17 @@ pub mod resolve_tree {
9132

9233
pub(super) mod function {
9334
use super::Outcome;
94-
9535
/// Given the `snapshot_tree` as previously returned via [super::create_tree::Outcome::snapshot_tree], extract data and…
9636
///
9737
/// * …cherry-pick the worktree changes onto the `target_worktree_tree_id`, which is assumed to represent the future working directory state
98-
/// and which either contains the worktree changes or *preferably* is the `HEAD^{tree}` as the working directory is clean.
38+
/// and which either contains the worktree changes or *preferably* is the `HEAD^{tree}` as the working directory is clean.
9939
/// * …reconstruct the index to write into `.git/index`, assuming that the current `.git/index` is clean.
10040
/// * …produce reference edits to put the workspace refs back into place with.
10141
/// * …produce metadata that if set will represent the metadata of the entire workspace.
10242
///
10343
/// Note that none of this data is actually manifested in the repository or working tree, they only exists as objects in the Git database,
10444
/// assuming in-memory objects aren't used in the repository.
105-
pub fn resolve_tree<'repo>(
45+
pub fn resolve_tree(
10646
_snapshot_tree: gix::Id<'_>,
10747
_target_worktree_tree_id: gix::ObjectId,
10848
) -> anyhow::Result<Outcome<'_>> {
@@ -152,13 +92,13 @@ mod commit {
15292
type Err = anyhow::Error;
15393

15494
fn from_str(s: &str) -> anyhow::Result<Self, Self::Err> {
155-
let parts: Vec<&str> = s.splitn(2, ':').collect();
156-
if parts.len() != 2 {
95+
let mut parts = s.splitn(2, ':');
96+
let (Some(key), Some(value)) = (parts.next(), parts.next()) else {
15797
return Err(anyhow!("Invalid trailer format, expected `key: value`"));
158-
}
159-
let unescaped_value = parts[1].trim().replace("\\n", "\n");
98+
};
99+
let unescaped_value = value.trim().replace("\\n", "\n");
160100
Ok(Self {
161-
key: parts[0].trim().to_string(),
101+
key: key.trim().to_string(),
162102
value: unescaped_value,
163103
})
164104
}
Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
use bstr::BString;
2+
use but_graph::VirtualBranchesTomlMetadata;
3+
use std::collections::BTreeSet;
4+
5+
/// A way to determine what should be included in the snapshot when calling [create_tree()](function::create_tree).
6+
#[derive(Debug, Clone)]
7+
pub struct State {
8+
/// The result of a previous worktree changes call.
9+
///
10+
/// It contains detailed information about the complete set of possible changes to become part of the worktree.
11+
pub changes: but_core::WorktreeChanges,
12+
/// Repository-relative and slash-separated paths that match any change in the [`changes`](State::changes) field.
13+
/// It is *not* error if there is no match, as there can be snapshots without working tree changes, but with other changes.
14+
/// It's up to the caller to check for that via [`Outcome::is_empty()`].
15+
pub selection: BTreeSet<BString>,
16+
/// If `true`, store the current `HEAD` reference, i.e. its target, as well as the targets of all refs it's pointing to by symbolic link.
17+
pub head: bool,
18+
}
19+
20+
/// Contains all state that the snapshot contains.
21+
#[derive(Debug, Copy, Clone)]
22+
pub struct Outcome {
23+
/// The snapshot itself, with all the subtrees available that are also listed in this structure.
24+
pub snapshot_tree: gix::ObjectId,
25+
/// For good measure, the input `HEAD^{tree}` that is used as the basis to learn about worktree changes.
26+
pub head_tree: gix::ObjectId,
27+
/// The `head_tree` with the selected worktree changes applied, suitable for being stored in a commit,
28+
/// or `None` if there was no change in the worktree.
29+
pub worktree: Option<gix::ObjectId>,
30+
/// The tree representing the current changed index, without conflicts, or `None` if there was no change to the index.
31+
pub index: Option<gix::ObjectId>,
32+
/// A tree with files in a custom storage format to allow keeping conflicting blobs reachable, along with detailed conflict information
33+
/// to allow restoring the conflict entries in the index.
34+
pub index_conflicts: Option<gix::ObjectId>,
35+
/// The tree representing the reference targets of all references within the *workspace*.
36+
pub workspace_references: Option<gix::ObjectId>,
37+
/// The tree representing the reference targets of all references reachable from `HEAD`, so typically `HEAD` itself, and the
38+
/// target object of the reference it is pointing to.
39+
pub head_references: Option<gix::ObjectId>,
40+
/// The tree representing the metadata of all references within the *workspace*.
41+
pub metadata: Option<gix::ObjectId>,
42+
}
43+
44+
impl Outcome {
45+
/// Return `true` if the snapshot contains no information whatsoever, which is equivalent to being an empty tree.
46+
pub fn is_empty(&self) -> bool {
47+
self.snapshot_tree.is_empty_tree()
48+
}
49+
}
50+
51+
/// A utility to more easily use *no* workspace or metadata.
52+
pub fn no_workspace_and_meta() -> Option<(
53+
&'static but_graph::projection::Workspace<'static>,
54+
&'static VirtualBranchesTomlMetadata,
55+
)> {
56+
None
57+
}
58+
59+
pub(super) mod function {
60+
use super::{Outcome, State};
61+
use crate::{DiffSpec, commit_engine};
62+
use anyhow::bail;
63+
use but_core::RefMetadata;
64+
use gix::object::tree::EntryKind;
65+
use tracing::instrument;
66+
67+
/// Create a tree that represents the snapshot for the given `selection`, with the basis for everything
68+
/// being the `head_tree_id` *(i.e. the tree to which `HEAD` is ultimately pointing to)*.
69+
/// Make this an empty tree if the `HEAD` is unborn.
70+
///
71+
/// If `workspace_and_meta` is not `None`, the workspace and metadata to store in the snapshot.
72+
/// We will only store reference positions, and assume that their commits are safely stored in the reflog to not
73+
/// be garbage collected. Metadata is only stored for the references that are included in the `workspace`.
74+
///
75+
/// Note that objects will be written into the repository behind `head_tree_id` unless it's configured
76+
/// to keep everything in memory.
77+
///
78+
/// ### Snapshot Tree Format
79+
///
80+
/// There are the following top-level trees, with their own sub-formats which aren't specified here.
81+
/// However, it's notable that they have to be implemented so that they remain compatible to prior versions
82+
/// of the tree.
83+
///
84+
/// Note that all top-level entries are optional, and only present if there is a snapshot to store.
85+
///
86+
/// * `worktree`
87+
/// - the tree of `HEAD + uncommitted files`. Technically this means that now possibly untracked files are known to Git,
88+
/// even though it might be that the respective objects aren't written to disk yet.
89+
#[instrument(skip(changes, _workspace_and_meta), err(Debug))]
90+
pub fn create_tree(
91+
head_tree_id: gix::Id<'_>,
92+
State {
93+
changes,
94+
selection,
95+
head: _,
96+
}: State,
97+
_workspace_and_meta: Option<(&but_graph::projection::Workspace, &impl RefMetadata)>,
98+
) -> anyhow::Result<Outcome> {
99+
let repo = head_tree_id.repo;
100+
// TODO: refactor tree-creation to not assume commits anymore.
101+
let mut changes_to_apply: Vec<_> = changes
102+
.changes
103+
.iter()
104+
.filter(|c| selection.contains(&c.path))
105+
.map(|c| Ok(DiffSpec::from(c)))
106+
.collect();
107+
let (new_tree, base_tree) = commit_engine::tree::apply_worktree_changes(
108+
head_tree_id.into(),
109+
repo,
110+
&mut changes_to_apply,
111+
0, /* context lines don't matter */
112+
)?;
113+
114+
let rejected = changes_to_apply
115+
.into_iter()
116+
.filter_map(Result::err)
117+
.collect::<Vec<_>>();
118+
if !rejected.is_empty() {
119+
bail!(
120+
"It should be impossible to fail to apply changes that are in the tree that was provided as HEAD^{{tree}} - {rejected:?}"
121+
)
122+
}
123+
124+
let mut edit = repo.empty_tree().edit()?;
125+
126+
let worktree = (new_tree != base_tree).then_some(new_tree.detach());
127+
if let Some(worktree) = worktree {
128+
edit.upsert("worktree", EntryKind::Tree, worktree)?;
129+
}
130+
131+
Ok(Outcome {
132+
snapshot_tree: edit.write()?.into(),
133+
head_tree: head_tree_id.into(),
134+
worktree,
135+
index: None,
136+
index_conflicts: None,
137+
workspace_references: None,
138+
head_references: None,
139+
metadata: None,
140+
})
141+
}
142+
}

crates/but-workspace/tests/fixtures/generated-archives/.gitignore

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,7 @@
66
/all-file-types-modified.tar
77
/merge-with-two-branches-line-offset-two-files.tar
88
/merge-with-two-branches-line-offset.tar
9-
/unborn-with-submodules.tar
10-
/unborn-untracked-crlf.tar
11-
/unborn-untracked-all-file-types.tar
12-
/unborn-untracked.tar
9+
/unborn-*.tar
1310
/all-file-types-renamed-and-modified.tar
1411
/merge-with-two-branches-auto-resolved-merge.tar
1512
/two-commits-three-buckets.tar
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
#!/usr/bin/env bash
2+
3+
### Description
4+
# A newly initialized git repository, with no additional content.
5+
git init

crates/but-workspace/tests/workspace/main.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ mod branch_details;
55
mod commit_engine;
66
mod flatten_diff_specs;
77
mod ref_info;
8+
mod snapshot;
89
mod tree_manipulation;
910
mod ui;
1011

0 commit comments

Comments
 (0)