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
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
//! Save index and working-tree snapshots without moving HEAD.
//!
//! A stash is an ordinary commit with two or three parents: the original HEAD, an index commit,
//! and optionally a root commit containing untracked files. [`Snapshot`] reads this shape from
//! Git-created objects. [`Objects::create_stash`] builds the same objects from explicit trees and
//! identities, and returns a separate cleanup plan. It never captures files, applies filters,
//! installs objects or updates `refs/stash` implicitly.
//!
//! [`Objects::apply_stash`] returns working/index merge outcomes with original conflict sides.
//! [`Apply::prepare_checkout`] composes them with guarded merge checkout. [`History`] observes
//! the files-backend reflog stack and plans conditional push/drop publication. Pop is successful
//! apply/checkout followed by an explicit drop; conflict or partial checkout must keep the entry.
//! See the [stash guide](https://github.com/joshka/girt/blob/main/docs/stashing-changes.md) for
//! capture ownership, publication ordering and recovery.
//!
//! Callers own path selection and snapshot capture. Supply the tracked working-tree tree separately
//! from the optional untracked tree; ignored files belong there only when the caller requests them.
//! Save the generated objects and publish the stash entry before removing saved working changes.
//! A staged-only cleanup can conflict even though the saved commits are valid. Preserve the saved
//! entry and let the caller resolve that cleanup.
//!
//! ```no_run
//! use std::sync::atomic::AtomicBool;
//!
//! use girt::stash::{SaveInput, SaveOptions};
//! use girt::{ObjectId, Objects, Signature};
//!
//! fn save(
//! objects: &Objects,
//! head: ObjectId,
//! index: ObjectId,
//! worktree: ObjectId,
//! author: Signature,
//! committer: Signature,
//! ) -> girt::Result<()> {
//! let input = SaveInput {
//! head,
//! index,
//! worktree,
//! untracked: None,
//! };
//! let plan = objects.create_stash(
//! input,
//! author,
//! committer,
//! SaveOptions::default(),
//! &AtomicBool::new(false),
//! )?;
//! if let Some(plan) = plan {
//! assert_eq!(plan.snapshot.index.parents(), &[head]);
//! // Install the supplied trees and plan.commits(), then publish refs/stash explicitly.
//! // Only after that succeeds, apply plan.cleanup and restore plan.index_after.
//! }
//! Ok(())
//! }
//! ```
use std::sync::atomic::AtomicBool;
mod apply;
pub use apply::{Apply, ApplyInput, ApplyOptions};
mod history;
pub use history::{History, LogEdit, LogError, LogLimits};
use crate::merge::{TreeMerge, TreeMergeOptions};
use crate::{Commit, CommitFields, ObjectId, Objects, Signature, Tree};
/// Saved commits, including the original base, with validated parent relationships.
#[derive(Clone, Debug)]
#[non_exhaustive]
pub struct Snapshot {
/// Worktree commit; its parents are base, index and optional untracked commit, in that order.
pub worktree: Commit,
/// Saved index commit, whose sole parent is the base.
pub index: Commit,
/// HEAD at save time.
pub base: Commit,
/// Optional parentless commit containing saved untracked/ignored files.
pub untracked: Option<Commit>,
}
impl Snapshot {
/// Reads a Git-created stash and verifies its two/three-parent shape.
///
/// Does not traverse the saved trees or infer whether their paths are tracked. Callers choose
/// object-read limits through their [`Objects`] handle. No repository state changes.
///
/// # Errors
///
/// Returns unreadable commits or [`Error::Shape`] for unsupported parent relationships.
pub fn read(objects: &Objects, id: ObjectId) -> Result<Self, Error> {
let worktree = objects.read_commit(id)?;
let [base, index, tail @ ..] = worktree.parents() else {
return Err(Error::Shape);
};
if tail.len() > 1 {
return Err(Error::Shape);
}
let base = objects.read_commit(*base)?;
let index = objects.read_commit(*index)?;
let untracked = tail
.first()
.map(|id| objects.read_commit(*id))
.transpose()?;
if index.parents() != [base.id()]
|| untracked
.as_ref()
.is_some_and(|commit| !commit.parents().is_empty())
{
return Err(Error::Shape);
}
Ok(Self {
worktree,
index,
base,
untracked,
})
}
/// Saved identity used in `refs/stash` and its reflog.
pub fn id(&self) -> ObjectId {
self.worktree.id()
}
}
/// Caller-captured trees; construction does not establish their connectivity or path selection.
#[derive(Clone, Copy, Debug)]
pub struct SaveInput {
/// Existing HEAD commit. Unborn repositories cannot create this stash shape.
pub head: ObjectId,
/// Complete stage-zero index snapshot; unmerged indexes must be resolved before capture.
pub index: ObjectId,
/// Tracked working files, including staged additions and deletions.
pub worktree: ObjectId,
/// Separate untracked tree (`--include-untracked`), including an empty tree if requested.
pub untracked: Option<ObjectId>,
}
/// Which changes to save and which tracked state to leave behind.
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
#[non_exhaustive]
pub enum SaveMode {
/// Save staged and unstaged changes, restoring base in both index and working files.
#[default]
All,
/// Save both kinds of changes, leaving the saved index in index and working files.
KeepIndex,
/// Save staged changes only; subtract them from working files and restore the base index.
Staged,
}
/// Message context, selection and cleanup merge policy.
#[derive(Clone, Debug)]
#[non_exhaustive]
pub struct SaveOptions {
/// Short branch label, or `(no branch)` for a detached HEAD. No ref lookup is performed.
pub branch: Vec<u8>,
/// Number of base-ID hex digits in default messages; Git's usual minimum is seven.
/// Callers must choose a repository-unique abbreviation when that matters.
pub abbreviation: usize,
/// Custom message body. `None` uses `WIP on branch: abbreviated-id subject`.
pub message: Option<Vec<u8>>,
/// Save/cleanup selection, independent of optional untracked capture.
pub mode: SaveMode,
/// Existing merge engine's bounds and conflict policy for staged-only cleanup.
pub trees: TreeMergeOptions,
}
impl Default for SaveOptions {
fn default() -> Self {
Self {
branch: b"(no branch)".to_vec(),
abbreviation: 7,
message: None,
mode: SaveMode::All,
trees: TreeMergeOptions::default(),
}
}
}
/// Saved commits and an independently computed tracked cleanup plan.
#[derive(Clone, Debug)]
#[non_exhaustive]
pub struct Save {
/// New stash shape; input trees must also be installed before publishing its identity.
pub snapshot: Snapshot,
/// Working-tree cleanup, including original conflict sides for staged-only overlap.
pub cleanup: TreeMerge,
/// Stage-zero index tree after successful cleanup.
pub index_after: ObjectId,
/// Whether caller-selected untracked paths should be removed after durable stash publication.
/// True means an untracked capture was requested, even if its tree is empty. There may be
/// no paths to remove. This is not permission to delete paths that changed after capture.
pub remove_untracked: bool,
}
impl Save {
/// New commits in dependency order. The existing base is not repeated.
pub fn commits(&self) -> impl Iterator<Item = &Commit> {
std::iter::once(&self.snapshot.index)
.chain(self.snapshot.untracked.iter())
.chain(std::iter::once(&self.snapshot.worktree))
}
}
/// Stash computation failed without changing repository state.
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum Error {
/// A commit could not be read.
#[error("reading stash commit")]
ReadCommit(#[from] crate::ReadCommitError),
/// A supplied tree could not be read.
#[error("reading stash tree")]
ReadTree(#[from] crate::ReadTreeError),
/// New commit metadata is invalid.
#[error("constructing stash commit")]
Commit(#[from] crate::CommitError),
/// Existing merge engine failed.
#[error("merging stash trees")]
Merge(#[from] crate::merge::RenameError),
/// Generated empty tree is invalid.
#[error("constructing stash tree")]
Tree(#[from] crate::TreeError),
/// Saved commits do not have the supported parent shape.
#[error("invalid stash commit shape")]
Shape,
/// Message context contains line separators/NUL, or abbreviation is outside the ID width.
#[error("invalid stash message context")]
Message,
/// Cancellation was observed before computation.
#[error("stash computation cancelled")]
Cancelled,
/// Index restoration must be resolved before working-tree publication.
#[error("stash index restoration conflicted")]
IndexConflict,
/// Existing merge checkout preparation failed.
#[error("preparing stash index")]
Preparation(#[from] crate::merge::PublicationError),
/// The final index cannot represent the proposed paths.
#[error("constructing stash index")]
Index(#[from] crate::index::Error),
/// Occupied path input exceeds preparation limits.
#[error("stash input limit exceeded")]
Limit,
/// Saved untracked data would overwrite an existing file or directory.
#[error("saved untracked path collides with current files: {0:?}")]
UntrackedCollision(Vec<u8>),
/// Git refuses staged-only saving with untracked capture.
#[error("staged-only stash cannot include untracked files")]
StagedUntracked,
}
impl Objects {
/// Constructs saved commits and cleanup, returning `None` when no selected changes exist.
///
/// Both identities are explicit, matching Git's separate author/committer environment.
/// Default index/untracked messages end in LF; the worktree message has no added final LF.
/// No trees, commits, refs, index or files are written. Staged cleanup uses the existing merge
/// engine; it can retain conflicts where Git's patch-based cleanup refuses an overlapping
/// worktree edit. Its conflict result never authorizes cleanup publication.
///
/// # Errors
///
/// Returns invalid input metadata, unsupported selection or object/merge errors. Tree roots
/// are read, but this does not prove recursive object connectivity.
pub fn create_stash(
&self,
input: SaveInput,
author: Signature,
committer: Signature,
options: SaveOptions,
cancel: &AtomicBool,
) -> Result<Option<Save>, Error> {
if cancel.load(std::sync::atomic::Ordering::Relaxed) {
return Err(Error::Cancelled);
}
if options.mode == SaveMode::Staged && input.untracked.is_some() {
return Err(Error::StagedUntracked);
}
let base = self.read_commit(input.head)?;
let base_tree = self.read_tree(base.tree())?;
let index = self.read_tree(input.index)?;
let working = self.read_tree(input.worktree)?;
let untracked = input.untracked.map(|id| self.read_tree(id)).transpose()?;
let selected = if options.mode == SaveMode::Staged {
&index
} else {
&working
};
if index.id() == base_tree.id()
&& selected.id() == base_tree.id()
&& untracked
.as_ref()
.is_none_or(|tree| tree.entries().is_empty())
{
return Ok(None);
}
let context = context(&base, &options)?;
let make = |tree, parents, message| {
Commit::new(CommitFields {
tree,
parents,
author: author.clone(),
committer: committer.clone(),
extra_headers: vec![],
message,
})
};
let index = make(
index.id(),
vec![base.id()],
[b"index on ".as_slice(), &context, b"\n"].concat(),
)?;
let untracked = untracked
.map(|tree| {
make(
tree.id(),
vec![],
[b"untracked files on ".as_slice(), &context, b"\n"].concat(),
)
})
.transpose()?;
let message = match &options.message {
Some(message) => [b"On ".as_slice(), &options.branch, b": ", message].concat(),
None => [b"WIP on ".as_slice(), &context].concat(),
};
let parents = std::iter::once(base.id())
.chain(std::iter::once(index.id()))
.chain(untracked.iter().map(Commit::id))
.collect();
let worktree = make(selected.id(), parents, message)?;
let (cleanup, index_after) = match options.mode {
SaveMode::All => (clean(base_tree.clone()), base_tree.id()),
SaveMode::KeepIndex => (clean(self.read_tree(index.tree())?), index.tree()),
SaveMode::Staged => {
let empty = Tree::new(base.id().format(), vec![])?;
let cleanup = crate::merge::ReplayTrees::default().merge(
self,
[input.index, input.worktree, base.tree()],
empty,
options.trees,
cancel,
None,
)?;
(cleanup, base.tree())
}
};
Ok(Some(Save {
snapshot: Snapshot {
worktree,
index,
base,
untracked,
},
cleanup,
index_after,
remove_untracked: input.untracked.is_some(),
}))
}
}
fn context(base: &Commit, options: &SaveOptions) -> Result<Vec<u8>, Error> {
if options
.branch
.iter()
.any(|b| matches!(b, 0 | b'\r' | b'\n'))
|| options.abbreviation == 0
|| options.abbreviation > base.id().to_string().len()
{
return Err(Error::Message);
}
let abbreviated: Vec<u8> = base
.id()
.to_string()
.bytes()
.take(options.abbreviation)
.collect();
Ok([
options.branch.as_slice(),
b": ",
&abbreviated,
b" ",
&base.subject(),
]
.concat())
}
fn clean(tree: Tree) -> TreeMerge {
TreeMerge {
tree: tree.clone(),
conflicts: vec![],
blobs: vec![],
trees: vec![tree],
}
}