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
use std::path::{Path, PathBuf};
use std::sync::atomic::AtomicBool;
use super::{Action, Error, Kind, Recovery, State};
use crate::{ObjectId, mailinfo};
impl State {
/// Prepares one queued am message after continuing or skipping the current message.
///
/// Extracts the next numbered message from this snapshot with explicit bounds. Persisted
/// subject retention, Message-ID, scissors and quiet options are admitted. Explicit scissors
/// overrides the repository's loaded `mailinfo.scissors` configuration. Signoff,
/// apply/three-way flags, quoted-CR policy and output encoding remain unsupported. All
/// numbered mail is retained, the next patch remains unapplied, and the abort-safety
/// checkpoint advances to the proposed destination. After caller-owned checkout/object/HEAD
/// publication, publish the returned metadata and stage the next patch
/// through [`crate::am::apply_current`] or Git. Git `am --continue` can then resume the series.
/// Final-mail completion and session abort use [`Self::prepare`].
///
/// Requires exclusive operation ownership across observation and publication. Extraction and
/// proposed metadata admission complete before returning. Publication replaces `next` last,
/// but is not a cross-file transaction; re-read state after any partial publication failure.
///
/// # Errors
///
/// Returns [`Error`] for non-am/final-mail state, unsupported options/actions, missing or
/// malformed mail, stale safety checkpoints, exceeded bounds or cancellation. Nonempty author,
/// email, date and subject fields are required; no current-time or missing-header fallback
/// runs.
pub fn prepare_next_am(
&self,
repository: &crate::Repository,
action: Action,
head: ObjectId,
limits: mailinfo::Limits,
cancel: &AtomicBool,
) -> Result<Recovery, Error> {
self.prepare_next_am_inner(repository, action, Some(head), limits, cancel)
}
fn prepare_next_am_inner(
&self,
repository: &crate::Repository,
action: Action,
head: Option<ObjectId>,
limits: mailinfo::Limits,
cancel: &AtomicBool,
) -> Result<Recovery, Error> {
if repository.git_dir() != self.git_dir || repository.object_format() != self.format {
return Err(Error::Changed);
}
for (section, key) in [("mailinfo", "quotedCr"), ("i18n", "commitEncoding")] {
if repository.config().string(section, None, key).is_some() {
return Err(Error::Unsupported(PathBuf::from(format!(
"config/{section}.{key}"
))));
}
}
if self.kind != Kind::Am || !matches!(action, Action::Continue(_) | Action::Skip) {
return Err(Error::Unsupported(PathBuf::from("rebase-apply")));
}
let (next, last) = super::state::am_range(&self.files)?;
if next >= last {
return Err(Error::Unsupported(PathBuf::from("rebase-apply/next")));
}
let options = self.am_mailinfo_options(repository)?;
let mut plan = self.prepare_inner(action, head, true)?;
let name = PathBuf::from(format!("rebase-apply/{:04}", next + 1));
let raw = self
.files
.get(&name)
.ok_or_else(|| Error::Unsupported(name.clone()))?;
let mail = mailinfo::parse_with(raw, options, limits, cancel)?;
let required = |value: Option<String>| {
value
.filter(|value| !value.is_empty())
.ok_or_else(|| Error::Unsupported(name.clone()))
};
let author = required(mail.author)?;
let email = required(mail.email)?;
let date = required(mail.date)?;
let subject = required(mail.subject)?;
if [&author, &email, &date, &subject]
.iter()
.any(|value| value.bytes().any(|byte| matches!(byte, 0 | b'\r' | b'\n')))
|| mail.patch.is_empty()
{
return Err(Error::Unsupported(name));
}
let mut script = Vec::new();
for (key, value) in [
("GIT_AUTHOR_NAME", &author),
("GIT_AUTHOR_EMAIL", &email),
("GIT_AUTHOR_DATE", &date),
] {
script.extend_from_slice(key.as_bytes());
script.extend_from_slice(b"='");
for byte in value.bytes() {
if matches!(byte, b'\'' | b'!') {
script.extend_from_slice(b"'\\");
script.push(byte);
script.push(b'\'');
} else {
script.push(byte);
}
}
script.extend_from_slice(b"'\n");
}
let mut message = subject.as_bytes().to_vec();
message.push(b'\n');
if !mail.message.is_empty() {
message.push(b'\n');
message.extend_from_slice(&mail.message);
}
let info =
format!("Author: {author}\nEmail: {email}\nSubject: {subject}\nDate: {date}\n\n");
for (path, bytes) in &self.files {
if path.starts_with("rebase-apply")
|| matches!(action, Action::Continue(_))
&& matches!(path.to_str(), Some("MERGE_MSG" | "AUTO_MERGE"))
{
plan.after.insert(path.clone(), bytes.clone());
}
}
for (name, bytes) in [
("next", format!("{}\n", next + 1).into_bytes()),
(
"abort-safety",
if head.is_none() && plan.destination.is_null() {
Vec::new()
} else {
format!("{}\n", plan.destination).into_bytes()
},
),
("author-script", script),
("final-commit", message),
("info", info.into_bytes()),
("msg", mail.message),
("patch", mail.patch),
] {
plan.after
.insert(Path::new("rebase-apply").join(name), bytes);
}
let total = plan
.after
.values()
.try_fold(0usize, |sum, bytes| sum.checked_add(bytes.len()))
.ok_or(Error::Limit)?;
if plan.after.len() > self.limits.max_files || total > self.limits.max_bytes {
return Err(Error::Limit);
}
Ok(plan)
}
fn am_mailinfo_options(
&self,
repository: &crate::Repository,
) -> Result<mailinfo::Options, Error> {
for (name, expected) in [
("apply-opt", b"".as_slice()),
("quoted-cr", b""),
("sign", b"f\n"),
("threeway", b"f\n"),
("utf8", b"t\n"),
] {
if self.am_option(name)? != expected {
return Err(Error::Unsupported(Path::new("rebase-apply").join(name)));
}
}
let subject = match self.am_option("keep")? {
b"f\n" => mailinfo::Subject::Clean,
b"t\n" => mailinfo::Subject::Keep,
b"b\n" => mailinfo::Subject::KeepNonPatch,
_ => return Err(Error::Unsupported(PathBuf::from("rebase-apply/keep"))),
};
let message_id = self.am_boolean("messageid")?;
// Quiet changes command output only; retain its exact bytes in the plan.
self.am_boolean("quiet")?;
let configured_scissors = repository
.config()
.boolean("mailinfo", None, "scissors")
.map_err(|_| Error::Unsupported(PathBuf::from("config/mailinfo.scissors")))?;
let scissors = if self.am_option("scissors")?.is_empty() {
configured_scissors.unwrap_or(false)
} else {
self.am_boolean("scissors")?
};
Ok(mailinfo::Options::default()
.with_subject(subject)
.with_message_id(message_id)
.with_scissors(scissors))
}
fn am_option(&self, name: &str) -> Result<&[u8], Error> {
let path = Path::new("rebase-apply").join(name);
self.files
.get(&path)
.map(Vec::as_slice)
.ok_or(Error::Unsupported(path))
}
fn am_boolean(&self, name: &str) -> Result<bool, Error> {
match self.am_option(name)? {
b"t\n" => Ok(true),
b"f\n" => Ok(false),
_ => Err(Error::Unsupported(Path::new("rebase-apply").join(name))),
}
}
}
/// Recovery for an am session that started on an unborn branch.
///
/// A continuation names a root commit; skip/abort retain an unborn branch. The caller owns
/// checkout/index restoration, object installation and conditional branch creation. Publish the
/// metadata only after those steps. This type retains the observed symbolic HEAD target and uses
/// the same bounded snapshot verification and rename-before-cleanup as [`Recovery`].
#[derive(Clone, Debug)]
pub struct UnbornAmRecovery {
recovery: Recovery,
branch: crate::refs::RefName,
}
impl State {
/// Prepares final am recovery while HEAD still names an unborn branch.
///
/// Requires no original HEAD and an exactly empty abort-safety checkpoint. Continue names a
/// caller-prepared root commit; skip/abort keep the branch unborn. Queued continuation uses
/// [`Self::prepare_next_unborn_am`]. Abort after earlier commits uses
/// [`Self::prepare_abort_to_unborn_am`]. No repository data is changed here.
///
/// # Errors
///
/// Returns [`Error`] for a foreign repository, born/detached HEAD, incompatible state, nonempty
/// checkpoint, original HEAD, unsupported action or queued continuation.
pub fn prepare_unborn_am(
&self,
repository: &crate::Repository,
action: Action,
) -> Result<UnbornAmRecovery, Error> {
let branch = self.unborn_am_branch(repository, action, None)?;
let recovery = self.prepare_inner(action, None, false)?;
Ok(UnbornAmRecovery { recovery, branch })
}
/// Prepares exactly one queued am message while the current branch is still unborn.
///
/// Continue names a caller-prepared root commit; skip keeps HEAD unborn. Captures the symbolic
/// branch and extracts the next numbered mail with the same policy and bounds as
/// [`Self::prepare_next_am`]. All numbered source mail is retained. Publish caller-owned
/// checkout/object/reference changes before the returned metadata, then stage the next patch.
/// A skipped message retains an empty abort-safety checkpoint; continuation records the root
/// commit. Abort uses [`Self::prepare_unborn_am`].
///
/// # Errors
///
/// Returns [`Error`] for born/foreign/detached state, original HEAD, unsupported
/// action/options, absent queued mail, malformed extraction, limits, cancellation or an
/// unsafe checkpoint.
pub fn prepare_next_unborn_am(
&self,
repository: &crate::Repository,
action: Action,
limits: mailinfo::Limits,
cancel: &AtomicBool,
) -> Result<UnbornAmRecovery, Error> {
let branch = self.unborn_am_branch(repository, action, None)?;
let recovery = self.prepare_next_am_inner(repository, action, None, limits, cancel)?;
Ok(UnbornAmRecovery { recovery, branch })
}
/// Prepares am abort back to an unborn branch after earlier mails committed.
///
/// Requires absent original HEAD, the caller's expected current commit and a matching
/// abort-safety checkpoint. Missing original HEAD is interpreted as Git's unborn-origin state.
/// Captures the symbolic branch; it must remain symbolic and resolve to no commit before
/// metadata publication. Restore an empty tracked index/worktree and conditionally delete the
/// branch at `head` through a dereferencing HEAD transaction first. Reflog policy is explicit
/// and caller-owned; this method changes no files, objects, references or logs.
///
/// # Errors
///
/// Returns [`Error`] for wrong repository/format, absent or changed HEAD, existing original
/// HEAD, detached HEAD, malformed checkpoint or a checkpoint differing from `head`.
pub fn prepare_abort_to_unborn_am(
&self,
repository: &crate::Repository,
head: ObjectId,
) -> Result<UnbornAmRecovery, Error> {
if head.is_null() || head.format() != self.format {
return Err(Error::Unsupported(PathBuf::from("HEAD")));
}
let branch = self.unborn_am_branch(repository, Action::Abort, Some(head))?;
let checkpoint = self
.files
.get(Path::new("rebase-apply/abort-safety"))
.ok_or_else(|| Error::Unsupported(PathBuf::from("rebase-apply/abort-safety")))?;
if checkpoint.trim_ascii() != head.to_string().as_bytes() {
return Err(Error::HeadChanged);
}
let mut after = self.files.clone();
after.retain(|path, _| {
!path.starts_with("rebase-apply")
&& !matches!(path.to_str(), Some("MERGE_MSG" | "AUTO_MERGE"))
});
let recovery = Recovery {
state: self.clone(),
after,
destination: ObjectId::null(self.format),
restore_branch: None,
expected_branch_tip: None,
reference_updates: Vec::new(),
reflog_message: b"am --abort".to_vec(),
};
Ok(UnbornAmRecovery { recovery, branch })
}
fn unborn_am_branch(
&self,
repository: &crate::Repository,
action: Action,
current_head: Option<ObjectId>,
) -> Result<crate::refs::RefName, Error> {
if repository.git_dir() != self.git_dir || repository.object_format() != self.format {
return Err(Error::Changed);
}
if self.kind != Kind::Am
|| repository.head()?.id != current_head
|| self.original_head.is_some()
|| super::state::am_original_head(repository, self.limits)?.is_some()
|| matches!(action, Action::Continue(id) if id.is_null())
{
return Err(Error::Unsupported(PathBuf::from("HEAD")));
}
let Some(crate::refs::Target::Symbolic(branch)) =
repository.references()?.read(&crate::refs::RefName::HEAD)?
else {
return Err(Error::Unsupported(PathBuf::from("HEAD")));
};
Ok(branch)
}
}
impl UnbornAmRecovery {
/// Required HEAD after caller-owned publication; `None` leaves the branch unborn.
pub fn destination(&self) -> Option<ObjectId> {
(!self.recovery.destination.is_null()).then_some(self.recovery.destination)
}
/// Borrows proposed operation-file bytes, or `None` for absent metadata.
pub fn file_after(&self, path: impl AsRef<Path>) -> Option<&[u8]> {
self.recovery.file_after(path)
}
/// Returns the continuation reflog message for the caller's root commit.
pub fn reflog_for(&self, commit: &crate::Commit) -> Vec<u8> {
self.recovery.reflog_for(commit)
}
/// Publishes mail state after the caller installs the required HEAD and index/worktree state.
///
/// Requires exclusive operation/reference ownership. Verifies the exact metadata snapshot,
/// original-HEAD absence, destination and captured symbolic branch. Cleanup failure reports
/// retained mail outside the active namespace through [`Error::Partial`]. Caller-owned effects
/// remain on error; there is no automatic rollback or cross-file crash transaction.
///
/// # Errors
///
/// Returns [`Error`] for changed HEAD/branch/location/metadata, I/O or partial cleanup.
pub fn publish_metadata(&self, repository: &crate::Repository) -> Result<(), Error> {
if repository.references()?.read(&crate::refs::RefName::HEAD)?
!= Some(crate::refs::Target::Symbolic(self.branch.clone()))
{
return Err(Error::Changed);
}
self.recovery
.publish_metadata_at(repository, self.destination())
}
}