Skip to main content

sley_sequencer/
lib.rs

1pub mod rebase;
2pub mod replay;
3
4use sley_core::{GitError, 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}
49
50#[derive(Debug, Clone, PartialEq, Eq)]
51pub struct CommitIndexOptions {
52    pub author: Vec<u8>,
53    pub committer: Vec<u8>,
54    pub message: Vec<u8>,
55    pub reflog_message: Vec<u8>,
56    /// `encoding` header value (`i18n.commitEncoding`); `None`/UTF-8 omits it.
57    pub encoding: Option<Vec<u8>>,
58}
59
60#[derive(Debug, Clone, PartialEq, Eq)]
61pub struct CommitIndexResult {
62    pub oid: ObjectId,
63    pub tree: ObjectId,
64    pub updated_ref: String,
65    pub parent: Option<ObjectId>,
66}
67
68#[derive(Debug, Clone, PartialEq, Eq)]
69pub struct TagCreate {
70    pub object: ObjectId,
71    pub object_type: ObjectType,
72    pub name: Vec<u8>,
73    pub tagger: Vec<u8>,
74    pub message: Vec<u8>,
75}
76
77pub fn create_commit(writer: &mut impl ObjectWriter, commit: CommitCreate) -> Result<ObjectId> {
78    let format = commit.tree.format();
79    for parent in &commit.parents {
80        if parent.format() != format {
81            return Err(GitError::InvalidObjectId(format!(
82                "parent {parent} uses {}, tree uses {}",
83                parent.format().name(),
84                format.name()
85            )));
86        }
87    }
88    let commit = Commit {
89        tree: commit.tree,
90        parents: commit.parents,
91        author: commit.author,
92        committer: commit.committer,
93        encoding: commit.encoding,
94        message: commit.message,
95    };
96    writer.write_object(EncodedObject::new(ObjectType::Commit, commit.write()))
97}
98
99pub fn create_annotated_tag(writer: &mut impl ObjectWriter, tag: TagCreate) -> Result<ObjectId> {
100    if tag
101        .name
102        .iter()
103        .chain(tag.tagger.iter())
104        .any(|byte| matches!(*byte, b'\n' | b'\r' | 0))
105    {
106        return Err(GitError::InvalidFormat(
107            "tag name and tagger must not contain control bytes".into(),
108        ));
109    }
110    let tag = Tag {
111        object: tag.object,
112        object_type: tag.object_type,
113        name: tag.name,
114        tagger: Some(tag.tagger),
115        message: tag.message,
116        raw_body: None,
117    };
118    writer.write_object(EncodedObject::new(ObjectType::Tag, tag.write()))
119}
120
121pub fn commit_index(
122    git_dir: impl AsRef<Path>,
123    format: sley_core::ObjectFormat,
124    options: CommitIndexOptions,
125) -> Result<CommitIndexResult> {
126    let git_dir = git_dir.as_ref();
127    let tree = sley_worktree::write_tree_from_index(git_dir, format)?;
128    commit_tree_with_amend(git_dir, format, tree, options, false)
129}
130
131pub fn amend_index(
132    git_dir: impl AsRef<Path>,
133    format: sley_core::ObjectFormat,
134    options: CommitIndexOptions,
135) -> Result<CommitIndexResult> {
136    let git_dir = git_dir.as_ref();
137    let tree = sley_worktree::write_tree_from_index(git_dir, format)?;
138    commit_tree_with_amend(git_dir, format, tree, options, true)
139}
140
141pub fn commit_tree_at_head(
142    git_dir: impl AsRef<Path>,
143    format: sley_core::ObjectFormat,
144    tree: ObjectId,
145    options: CommitIndexOptions,
146) -> Result<CommitIndexResult> {
147    commit_tree_with_amend(git_dir, format, tree, options, false)
148}
149
150pub fn commit_tree_at_head_with_odb(
151    git_dir: impl AsRef<Path>,
152    format: sley_core::ObjectFormat,
153    tree: ObjectId,
154    options: CommitIndexOptions,
155    db: &FileObjectDatabase,
156) -> Result<CommitIndexResult> {
157    commit_tree_with_amend_with_odb(git_dir, format, tree, options, false, db)
158}
159
160fn commit_tree_with_amend(
161    git_dir: impl AsRef<Path>,
162    format: sley_core::ObjectFormat,
163    tree: ObjectId,
164    options: CommitIndexOptions,
165    amend: bool,
166) -> Result<CommitIndexResult> {
167    let git_dir = git_dir.as_ref();
168    let db = FileObjectDatabase::from_git_dir(git_dir, format);
169    commit_tree_with_amend_with_odb(git_dir, format, tree, options, amend, &db)
170}
171
172fn commit_tree_with_amend_with_odb(
173    git_dir: impl AsRef<Path>,
174    format: sley_core::ObjectFormat,
175    tree: ObjectId,
176    options: CommitIndexOptions,
177    amend: bool,
178    db: &FileObjectDatabase,
179) -> Result<CommitIndexResult> {
180    let git_dir = git_dir.as_ref();
181    let refs = FileRefStore::new(git_dir, format);
182    let (updated_ref, parent) = head_update_target(&refs)?;
183    let commit_parents = if amend {
184        let Some(parent) = &parent else {
185            return Err(GitError::not_found("commit to amend"));
186        };
187        let object = db.read_object(parent)?;
188        if object.object_type != ObjectType::Commit {
189            return Err(GitError::InvalidObject(format!(
190                "expected commit {}, found {}",
191                parent,
192                object.object_type.as_str()
193            )));
194        }
195        Commit::parse_ref(format, &object.body)?.parents
196    } else {
197        parent.iter().cloned().collect()
198    };
199    let mut writer = db.clone();
200    let oid = create_commit(
201        &mut writer,
202        CommitCreate {
203            tree: tree.clone(),
204            parents: commit_parents,
205            author: options.author,
206            committer: options.committer.clone(),
207            message: options.message,
208            encoding: options.encoding,
209        },
210    )?;
211    let expected = parent.map(RefTarget::Direct);
212    let old_oid = parent.unwrap_or(zero_oid(format)?);
213    let mut tx = refs.transaction();
214    tx.update(RefUpdate {
215        name: updated_ref.clone(),
216        expected,
217        new: RefTarget::Direct(oid),
218        reflog: Some(ReflogEntry {
219            old_oid,
220            new_oid: oid,
221            committer: options.committer,
222            message: options.reflog_message,
223        }),
224    });
225    tx.commit()?;
226    Ok(CommitIndexResult {
227        oid,
228        tree,
229        updated_ref,
230        parent,
231    })
232}
233
234pub fn format_commit_identity(name: &str, email: &str, date: &str) -> Result<Vec<u8>> {
235    validate_identity_component("name", name)?;
236    validate_identity_component("email", email)?;
237    let (seconds, timezone) = parse_raw_git_date(date)?;
238    Ok(format!("{name} <{email}> {seconds} {timezone}").into_bytes())
239}
240
241pub fn commit_message_from_chunks(chunks: &[Vec<u8>]) -> Vec<u8> {
242    let mut out = Vec::new();
243    for (idx, chunk) in chunks.iter().enumerate() {
244        if idx != 0 {
245            out.push(b'\n');
246        }
247        out.extend_from_slice(chunk);
248        out.push(b'\n');
249    }
250    out
251}
252
253fn head_update_target(refs: &FileRefStore) -> Result<(String, Option<ObjectId>)> {
254    match refs.read_ref("HEAD")? {
255        Some(RefTarget::Symbolic(name)) => match refs.read_ref(&name)? {
256            Some(RefTarget::Direct(oid)) => Ok((name, Some(oid))),
257            Some(RefTarget::Symbolic(_)) => Err(GitError::InvalidFormat(
258                "nested symbolic HEAD target is unsupported".into(),
259            )),
260            None => Ok((name, None)),
261        },
262        Some(RefTarget::Direct(oid)) => Ok(("HEAD".into(), Some(oid))),
263        None => Ok(("HEAD".into(), None)),
264    }
265}
266
267fn zero_oid(format: sley_core::ObjectFormat) -> Result<ObjectId> {
268    Ok(ObjectId::null(format))
269}
270
271fn validate_identity_component(name: &str, value: &str) -> Result<()> {
272    if value.bytes().any(|byte| matches!(byte, b'\n' | b'\r' | 0)) {
273        return Err(GitError::InvalidFormat(format!(
274            "commit identity {name} contains a control byte"
275        )));
276    }
277    Ok(())
278}
279
280fn parse_raw_git_date(date: &str) -> Result<(i64, String)> {
281    let mut parts = date.split_whitespace();
282    let seconds = parts
283        .next()
284        .ok_or_else(|| GitError::InvalidFormat("missing commit date seconds".into()))?;
285    let timezone = parts
286        .next()
287        .ok_or_else(|| GitError::InvalidFormat("missing commit date timezone".into()))?;
288    if parts.next().is_some() {
289        return Err(GitError::InvalidFormat(
290            "commit date has trailing fields".into(),
291        ));
292    }
293    let seconds = seconds.strip_prefix('@').unwrap_or(seconds);
294    let seconds = seconds
295        .parse::<i64>()
296        .map_err(|_| GitError::InvalidFormat("invalid commit date seconds".into()))?;
297    validate_timezone(timezone)?;
298    Ok((seconds, timezone.to_string()))
299}
300
301fn validate_timezone(timezone: &str) -> Result<()> {
302    let bytes = timezone.as_bytes();
303    if bytes.len() != 5
304        || !matches!(bytes[0], b'+' | b'-')
305        || !bytes[1..].iter().all(u8::is_ascii_digit)
306    {
307        return Err(GitError::InvalidFormat(format!(
308            "invalid commit timezone {timezone}"
309        )));
310    }
311    Ok(())
312}
313
314#[cfg(test)]
315mod tests {
316    use super::*;
317    use sley_core::ObjectFormat;
318    use sley_odb::ObjectDatabase;
319
320    #[test]
321    fn commit_identity_formats_raw_git_date() {
322        let identity =
323            format_commit_identity("Example User", "example@example.invalid", "@0 +0000")
324                .expect("test operation should succeed");
325        assert_eq!(identity, b"Example User <example@example.invalid> 0 +0000");
326    }
327
328    #[test]
329    fn create_commit_writes_commit_object() {
330        let tree = ObjectId::from_hex(
331            ObjectFormat::Sha1,
332            "4b825dc642cb6eb9a060e54bf8d69288fbee4904",
333        )
334        .expect("test operation should succeed");
335        let identity =
336            format_commit_identity("Example User", "example@example.invalid", "@0 +0000")
337                .expect("test operation should succeed");
338        let mut db = ObjectDatabase::new(ObjectFormat::Sha1);
339        let oid = create_commit(
340            &mut db,
341            CommitCreate {
342                tree,
343                parents: Vec::new(),
344                author: identity.clone(),
345                committer: identity,
346                message: b"initial subject\n".to_vec(),
347                encoding: None,
348            },
349        )
350        .expect("test operation should succeed");
351        assert_eq!(oid.to_hex(), "e7556fb3ba7b8f5b1f4772180772a4d6a7323e15");
352    }
353
354    #[test]
355    fn create_annotated_tag_writes_tag_object() {
356        let target = ObjectId::from_hex(
357            ObjectFormat::Sha1,
358            "e7556fb3ba7b8f5b1f4772180772a4d6a7323e15",
359        )
360        .expect("test operation should succeed");
361        let tagger = format_commit_identity("Example User", "example@example.invalid", "@0 +0000")
362            .expect("test operation should succeed");
363        let mut db = ObjectDatabase::new(ObjectFormat::Sha1);
364        let oid = create_annotated_tag(
365            &mut db,
366            TagCreate {
367                object: target,
368                object_type: ObjectType::Commit,
369                name: b"v1.0".to_vec(),
370                tagger,
371                message: b"release\n".to_vec(),
372            },
373        )
374        .expect("test operation should succeed");
375        assert_eq!(oid.to_hex(), "b9c6a18e58a4efa0a5c023bcf0d8f2a320ae4098");
376    }
377}