Skip to main content

sley_sequencer/
lib.rs

1pub mod rebase;
2pub mod replay;
3
4use sley_core::{GitError, ObjectFormat, ObjectId, Result};
5use sley_object::{Commit, EncodedObject, ObjectType, Tag};
6use sley_odb::FileObjectDatabase;
7use sley_odb::ObjectReader;
8use sley_odb::ObjectWriter;
9use sley_refs::{FileRefStore, RefTarget, RefUpdate, ReflogEntry};
10use std::path::Path;
11
12#[derive(Debug, Clone, PartialEq, Eq)]
13pub enum SequencerCommand {
14    Pick(ObjectId),
15    Revert(ObjectId),
16    Edit(ObjectId),
17    Squash(ObjectId),
18    Fixup(ObjectId),
19    Exec(Vec<u8>),
20}
21
22#[derive(Debug, Clone, PartialEq, Eq)]
23pub struct SequencerTodo {
24    pub commands: Vec<SequencerCommand>,
25}
26
27#[derive(Debug, Clone, PartialEq, Eq)]
28pub enum HistoryOperation {
29    Commit,
30    CherryPick,
31    Revert,
32    Rebase,
33    Bisect,
34    Stash,
35    Notes,
36    History,
37}
38
39#[derive(Debug, Clone, PartialEq, Eq)]
40pub struct CommitCreate {
41    pub tree: ObjectId,
42    pub parents: Vec<ObjectId>,
43    pub author: Vec<u8>,
44    pub committer: Vec<u8>,
45    pub message: Vec<u8>,
46    /// `encoding` header value (`i18n.commitEncoding`); `None`/UTF-8 omits it.
47    pub encoding: Option<Vec<u8>>,
48    pub signature: Option<Vec<u8>>,
49}
50
51#[derive(Debug, Clone, PartialEq, Eq)]
52pub struct CommitIndexOptions {
53    pub author: Vec<u8>,
54    pub committer: Vec<u8>,
55    pub message: Vec<u8>,
56    pub reflog_message: Vec<u8>,
57    /// `encoding` header value (`i18n.commitEncoding`); `None`/UTF-8 omits it.
58    pub encoding: Option<Vec<u8>>,
59    pub signature: Option<Vec<u8>>,
60}
61
62#[derive(Debug, Clone, PartialEq, Eq)]
63pub struct CommitIndexResult {
64    pub oid: ObjectId,
65    pub tree: ObjectId,
66    pub updated_ref: String,
67    pub parent: Option<ObjectId>,
68}
69
70#[derive(Debug, Clone, PartialEq, Eq)]
71pub struct TagCreate {
72    pub object: ObjectId,
73    pub object_type: ObjectType,
74    pub name: Vec<u8>,
75    pub tagger: Vec<u8>,
76    pub message: Vec<u8>,
77}
78
79pub fn create_commit(writer: &mut impl ObjectWriter, commit: CommitCreate) -> Result<ObjectId> {
80    let format = commit.tree.format();
81    for parent in &commit.parents {
82        if parent.format() != format {
83            return Err(GitError::InvalidObjectId(format!(
84                "parent {parent} uses {}, tree uses {}",
85                parent.format().name(),
86                format.name()
87            )));
88        }
89    }
90    let signature = commit.signature;
91    let commit = Commit {
92        tree: commit.tree,
93        parents: commit.parents,
94        author: commit.author,
95        committer: commit.committer,
96        encoding: commit.encoding,
97        message: commit.message,
98    };
99    let mut body = commit.write();
100    if let Some(signature) = signature {
101        body = commit_body_with_signature(format, &body, &signature);
102    }
103    writer.write_object(EncodedObject::new(ObjectType::Commit, body))
104}
105
106fn commit_body_with_signature(format: ObjectFormat, body: &[u8], signature: &[u8]) -> Vec<u8> {
107    let Some(split) = body.windows(2).position(|window| window == b"\n\n") else {
108        return body.to_vec();
109    };
110    let mut out = Vec::with_capacity(body.len() + signature.len() + signature.len() / 70 + 16);
111    out.extend_from_slice(&body[..split]);
112    out.push(b'\n');
113    out.extend_from_slice(match format {
114        ObjectFormat::Sha1 => b"gpgsig ",
115        ObjectFormat::Sha256 => b"gpgsig-sha256 ",
116    });
117    append_folded_signature(&mut out, signature);
118    out.extend_from_slice(&body[split + 1..]);
119    out
120}
121
122fn append_folded_signature(out: &mut Vec<u8>, signature: &[u8]) {
123    let mut first = true;
124    let mut lines = signature.split(|byte| *byte == b'\n').peekable();
125    while let Some(line) = lines.next() {
126        if line.is_empty() && lines.peek().is_none() && signature.ends_with(b"\n") {
127            continue;
128        }
129        if !first {
130            out.push(b' ');
131        }
132        out.extend_from_slice(line);
133        out.push(b'\n');
134        first = false;
135    }
136}
137
138pub fn create_annotated_tag(writer: &mut impl ObjectWriter, tag: TagCreate) -> Result<ObjectId> {
139    if tag
140        .name
141        .iter()
142        .chain(tag.tagger.iter())
143        .any(|byte| matches!(*byte, b'\n' | b'\r' | 0))
144    {
145        return Err(GitError::InvalidFormat(
146            "tag name and tagger must not contain control bytes".into(),
147        ));
148    }
149    let tag = Tag {
150        object: tag.object,
151        object_type: tag.object_type,
152        name: tag.name,
153        tagger: Some(tag.tagger),
154        message: tag.message,
155        raw_body: None,
156    };
157    writer.write_object(EncodedObject::new(ObjectType::Tag, tag.write()))
158}
159
160pub fn commit_index(
161    git_dir: impl AsRef<Path>,
162    format: sley_core::ObjectFormat,
163    options: CommitIndexOptions,
164) -> Result<CommitIndexResult> {
165    let git_dir = git_dir.as_ref();
166    let tree = sley_worktree::write_tree_from_index(git_dir, format)?;
167    commit_tree_with_amend(git_dir, format, tree, options, false)
168}
169
170pub fn amend_index(
171    git_dir: impl AsRef<Path>,
172    format: sley_core::ObjectFormat,
173    options: CommitIndexOptions,
174) -> Result<CommitIndexResult> {
175    let git_dir = git_dir.as_ref();
176    let tree = sley_worktree::write_tree_from_index(git_dir, format)?;
177    commit_tree_with_amend(git_dir, format, tree, options, true)
178}
179
180pub fn commit_tree_at_head(
181    git_dir: impl AsRef<Path>,
182    format: sley_core::ObjectFormat,
183    tree: ObjectId,
184    options: CommitIndexOptions,
185) -> Result<CommitIndexResult> {
186    commit_tree_with_amend(git_dir, format, tree, options, false)
187}
188
189pub fn commit_tree_at_head_with_odb(
190    git_dir: impl AsRef<Path>,
191    format: sley_core::ObjectFormat,
192    tree: ObjectId,
193    options: CommitIndexOptions,
194    db: &FileObjectDatabase,
195) -> Result<CommitIndexResult> {
196    commit_tree_with_amend_with_odb(git_dir, format, tree, options, false, db)
197}
198
199fn commit_tree_with_amend(
200    git_dir: impl AsRef<Path>,
201    format: sley_core::ObjectFormat,
202    tree: ObjectId,
203    options: CommitIndexOptions,
204    amend: bool,
205) -> Result<CommitIndexResult> {
206    let git_dir = git_dir.as_ref();
207    let db = FileObjectDatabase::from_git_dir(git_dir, format);
208    commit_tree_with_amend_with_odb(git_dir, format, tree, options, amend, &db)
209}
210
211fn commit_tree_with_amend_with_odb(
212    git_dir: impl AsRef<Path>,
213    format: sley_core::ObjectFormat,
214    tree: ObjectId,
215    options: CommitIndexOptions,
216    amend: bool,
217    db: &FileObjectDatabase,
218) -> Result<CommitIndexResult> {
219    let git_dir = git_dir.as_ref();
220    let refs = FileRefStore::new(git_dir, format);
221    let (updated_ref, parent) = head_update_target(&refs)?;
222    let commit_parents = if amend {
223        let Some(parent) = &parent else {
224            return Err(GitError::not_found("commit to amend"));
225        };
226        let object = db.read_object(parent)?;
227        if object.object_type != ObjectType::Commit {
228            return Err(GitError::InvalidObject(format!(
229                "expected commit {}, found {}",
230                parent,
231                object.object_type.as_str()
232            )));
233        }
234        Commit::parse_ref(format, &object.body)?.parents
235    } else {
236        parent.iter().cloned().collect()
237    };
238    let mut writer = db.clone();
239    let oid = create_commit(
240        &mut writer,
241        CommitCreate {
242            tree: tree.clone(),
243            parents: commit_parents,
244            author: options.author,
245            committer: options.committer.clone(),
246            message: options.message,
247            encoding: options.encoding,
248            signature: options.signature,
249        },
250    )?;
251    let expected = parent.map(RefTarget::Direct);
252    let old_oid = parent.unwrap_or(zero_oid(format)?);
253    let reflog = refs
254        .should_write_reflog_for_update(&updated_ref, false)?
255        .then(|| ReflogEntry {
256            old_oid,
257            new_oid: oid,
258            committer: options.committer,
259            message: options.reflog_message,
260        });
261    let mut tx = refs.transaction();
262    tx.update(RefUpdate {
263        name: updated_ref.clone(),
264        expected,
265        new: RefTarget::Direct(oid),
266        reflog,
267    });
268    tx.commit()?;
269    Ok(CommitIndexResult {
270        oid,
271        tree,
272        updated_ref,
273        parent,
274    })
275}
276
277pub fn format_commit_identity(name: &str, email: &str, date: &str) -> Result<Vec<u8>> {
278    format_commit_identity_bytes(name.as_bytes(), email.as_bytes(), date)
279}
280
281pub fn format_commit_identity_bytes(name: &[u8], email: &[u8], date: &str) -> Result<Vec<u8>> {
282    validate_identity_component_bytes("name", name)?;
283    validate_identity_component_bytes("email", email)?;
284    let (seconds, timezone) = parse_raw_git_date(date)?;
285    let mut out = Vec::with_capacity(name.len() + email.len() + timezone.len() + 32);
286    out.extend_from_slice(name);
287    out.extend_from_slice(b" <");
288    out.extend_from_slice(email);
289    out.extend_from_slice(b"> ");
290    out.extend_from_slice(seconds.to_string().as_bytes());
291    out.push(b' ');
292    out.extend_from_slice(timezone.as_bytes());
293    Ok(out)
294}
295
296pub fn commit_message_from_chunks(chunks: &[Vec<u8>]) -> Vec<u8> {
297    let mut out = Vec::new();
298    for (idx, chunk) in chunks.iter().enumerate() {
299        if idx != 0 {
300            out.push(b'\n');
301        }
302        out.extend_from_slice(chunk);
303        out.push(b'\n');
304    }
305    out
306}
307
308fn head_update_target(refs: &FileRefStore) -> Result<(String, Option<ObjectId>)> {
309    match refs.read_ref("HEAD")? {
310        Some(RefTarget::Symbolic(name)) => match refs.read_ref(&name)? {
311            Some(RefTarget::Direct(oid)) => Ok((name, Some(oid))),
312            Some(RefTarget::Symbolic(_)) => Err(GitError::InvalidFormat(
313                "nested symbolic HEAD target is unsupported".into(),
314            )),
315            None => Ok((name, None)),
316        },
317        Some(RefTarget::Direct(oid)) => Ok(("HEAD".into(), Some(oid))),
318        None => Ok(("HEAD".into(), None)),
319    }
320}
321
322fn zero_oid(format: sley_core::ObjectFormat) -> Result<ObjectId> {
323    Ok(ObjectId::null(format))
324}
325
326fn validate_identity_component_bytes(name: &str, value: &[u8]) -> Result<()> {
327    if value.iter().any(|byte| matches!(*byte, b'\n' | b'\r' | 0)) {
328        return Err(GitError::InvalidFormat(format!(
329            "commit identity {name} contains a control byte"
330        )));
331    }
332    Ok(())
333}
334
335fn parse_raw_git_date(date: &str) -> Result<(i64, String)> {
336    let mut parts = date.split_whitespace();
337    let seconds = parts
338        .next()
339        .ok_or_else(|| GitError::InvalidFormat("missing commit date seconds".into()))?;
340    let timezone = parts
341        .next()
342        .ok_or_else(|| GitError::InvalidFormat("missing commit date timezone".into()))?;
343    if parts.next().is_some() {
344        return Err(GitError::InvalidFormat(
345            "commit date has trailing fields".into(),
346        ));
347    }
348    let seconds = seconds.strip_prefix('@').unwrap_or(seconds);
349    let seconds = seconds
350        .parse::<i64>()
351        .map_err(|_| GitError::InvalidFormat("invalid commit date seconds".into()))?;
352    validate_timezone(timezone)?;
353    Ok((seconds, timezone.to_string()))
354}
355
356fn validate_timezone(timezone: &str) -> Result<()> {
357    let bytes = timezone.as_bytes();
358    if bytes.len() != 5
359        || !matches!(bytes[0], b'+' | b'-')
360        || !bytes[1..].iter().all(u8::is_ascii_digit)
361    {
362        return Err(GitError::InvalidFormat(format!(
363            "invalid commit timezone {timezone}"
364        )));
365    }
366    Ok(())
367}
368
369#[cfg(test)]
370mod tests {
371    use super::*;
372    use sley_core::ObjectFormat;
373    use sley_odb::ObjectDatabase;
374
375    #[test]
376    fn commit_identity_formats_raw_git_date() {
377        let identity =
378            format_commit_identity("Example User", "example@example.invalid", "@0 +0000")
379                .expect("test operation should succeed");
380        assert_eq!(identity, b"Example User <example@example.invalid> 0 +0000");
381    }
382
383    #[test]
384    fn create_commit_writes_commit_object() {
385        let tree = ObjectId::from_hex(
386            ObjectFormat::Sha1,
387            "4b825dc642cb6eb9a060e54bf8d69288fbee4904",
388        )
389        .expect("test operation should succeed");
390        let identity =
391            format_commit_identity("Example User", "example@example.invalid", "@0 +0000")
392                .expect("test operation should succeed");
393        let mut db = ObjectDatabase::new(ObjectFormat::Sha1);
394        let oid = create_commit(
395            &mut db,
396            CommitCreate {
397                tree,
398                parents: Vec::new(),
399                author: identity.clone(),
400                committer: identity,
401                message: b"initial subject\n".to_vec(),
402                encoding: None,
403                signature: None,
404            },
405        )
406        .expect("test operation should succeed");
407        assert_eq!(oid.to_hex(), "e7556fb3ba7b8f5b1f4772180772a4d6a7323e15");
408    }
409
410    #[test]
411    fn create_annotated_tag_writes_tag_object() {
412        let target = ObjectId::from_hex(
413            ObjectFormat::Sha1,
414            "e7556fb3ba7b8f5b1f4772180772a4d6a7323e15",
415        )
416        .expect("test operation should succeed");
417        let tagger = format_commit_identity("Example User", "example@example.invalid", "@0 +0000")
418            .expect("test operation should succeed");
419        let mut db = ObjectDatabase::new(ObjectFormat::Sha1);
420        let oid = create_annotated_tag(
421            &mut db,
422            TagCreate {
423                object: target,
424                object_type: ObjectType::Commit,
425                name: b"v1.0".to_vec(),
426                tagger,
427                message: b"release\n".to_vec(),
428            },
429        )
430        .expect("test operation should succeed");
431        assert_eq!(oid.to_hex(), "b9c6a18e58a4efa0a5c023bcf0d8f2a320ae4098");
432    }
433}