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
//! Inspect a stopped patch-mail application and explicitly stage its current patch.
//!
//! Git stores `am` progress in the worktree-private `rebase-apply` directory. Take a bounded
//! [`OperationState`] snapshot, then use [`State::from_operation`] to interpret its mail range.
//! Artifacts borrow the snapshot unchanged. Author scripts are untrusted shell text: this module
//! never executes them. Inspection retains their exact bytes. It does not apply patches, install
//! objects, change the index or working files, publish references, or authorize cleanup.
//!
//! ```no_run
//! use girt::am::State;
//! use girt::{OperationLimits, Repository};
//!
//! let repo = Repository::open("repository")?;
//! let snapshot = repo.operation_state(OperationLimits::default())?;
//! if let Some(state) = State::from_operation(&snapshot)? {
//! println!("next mail: {} of {}", state.next, state.last);
//! if let Some(patch) = state.patch {
//! println!("extracted patch: {} bytes", patch.len());
//! }
//! }
//! # Ok::<(), girt::Error>(())
//! ```
//!
//! [`apply_current`] is a separate mutation: it installs the extracted patch in working files and
//! the index while leaving HEAD and operation metadata for Git `am --continue`. It requires a
//! raw-clean tracked state, caller exclusion of writers, and explicit handling of partial working
//! changes in [`ApplyFailure`]. It does not create commits or advance the mail range.
//!
//! [`State::prepare_commit`] separately decodes the fixed author assignments and proposes an
//! unsigned commit from an explicit tree, parent and committer without installing it.
//!
//! Complete final-mail continue/skip or session abort through
//! [`crate::sequencer::State::read_repository`] and an explicit recovery plan. Checkout and
//! reference publication remain caller-owned before the shared metadata publisher runs.
//!
//! Snapshot limits bound all retained files and directories before interpretation. Callers must
//! exclude concurrent operation writers while taking the snapshot; an interpreted state is an
//! observation, not a lock or a validated resume plan. Apply-backend rebase metadata is refused.
use std::path::Path;
use crate::OperationState;
mod commit;
pub use commit::{CommitLimits, PrepareError};
mod stage;
pub use stage::{ApplyFailure, ApplyLimits, apply_current};
#[cfg(test)]
mod apply_tests;
/// Borrowed progress and artifacts from an `am` operation snapshot.
///
/// Optional extracted files can be absent after interruption before mail preparation. Their
/// presence does not establish that they agree with the original mail, index or HEAD. Unknown
/// options and auxiliary files remain available through the original [`OperationState`].
#[derive(Debug)]
#[non_exhaustive]
pub struct State<'a> {
/// One-based next mail number; `last + 1` represents a completed range awaiting cleanup.
pub next: usize,
/// Final mail number, always positive.
pub last: usize,
/// Original numbered message, absent only when the range is complete.
pub mail: Option<&'a [u8]>,
/// Extracted patch bytes, if Git prepared them.
pub patch: Option<&'a [u8]>,
/// Proposed commit-message bytes, if Git prepared them.
pub message: Option<&'a [u8]>,
/// Opaque author-script bytes; never execute untrusted snapshot content.
pub author_script: Option<&'a [u8]>,
}
/// Mail state cannot be interpreted or is not ready for staging.
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum Error {
/// The current operation or repository is not ready for guarded patch staging.
#[error("am patch cannot be staged: {0}")]
NotReady(&'static str),
/// Cancellation was observed before working-file publication.
#[error("am patch staging cancelled")]
Cancelled,
/// A `rebase-apply` root exists without the `applying` marker, or has a `rebasing` marker.
#[error("rebase-apply metadata is not an am operation")]
NotAm,
/// A required counter, range or current mail is missing or malformed.
#[error("malformed am state: {0}")]
Malformed(&'static str),
}
impl<'a> State<'a> {
/// Interprets one already-bounded operation snapshot without additional filesystem reads.
///
/// Returns `None` when `rebase-apply` is absent. Numbered messages use Git's minimum four-digit
/// padding. Only the active mail is required; earlier/future mail and extracted artifacts are
/// not validated. No script, option file or author identity is interpreted.
///
/// # Errors
///
/// Returns [`Error::NotAm`] for non-`am` metadata and [`Error::Malformed`] for missing/invalid
/// counters, an impossible range, or missing active mail. Work is linear in counter bytes;
/// returned artifacts borrow the snapshot and allocate no additional body buffers.
pub fn from_operation(snapshot: &'a OperationState) -> Result<Option<Self>, Error> {
if !snapshot.roots().any(|root| root == "rebase-apply") {
return Ok(None);
}
let file = |name: &str| snapshot.file(&Path::new("rebase-apply").join(name));
if file("applying").is_none() || file("rebasing").is_some() {
return Err(Error::NotAm);
}
let (next, last) = mail_range(file("next"), file("last"))?;
let mail = if next <= last {
Some(file(&format!("{next:04}")).ok_or(Error::Malformed("current mail"))?)
} else {
None
};
Ok(Some(Self {
next,
last,
mail,
patch: file("patch"),
message: file("final-commit"),
author_script: file("author-script"),
}))
}
}
pub(crate) fn mail_range(
next: Option<&[u8]>,
last: Option<&[u8]>,
) -> Result<(usize, usize), Error> {
let next = counter(next, "next")?;
let last = counter(last, "last")?;
if next == 0 || last == 0 || next - 1 > last {
return Err(Error::Malformed("mail range"));
}
Ok((next, last))
}
fn counter(bytes: Option<&[u8]>, name: &'static str) -> Result<usize, Error> {
let bytes = bytes.ok_or(Error::Malformed(name))?;
let bytes = bytes.strip_suffix(b"\n").unwrap_or(bytes);
if bytes.is_empty() {
return Err(Error::Malformed(name));
}
bytes.iter().try_fold(0usize, |value, byte| {
if !byte.is_ascii_digit() {
return Err(Error::Malformed(name));
}
value
.checked_mul(10)
.and_then(|value| value.checked_add(usize::from(byte - b'0')))
.ok_or(Error::Malformed(name))
})
}