Skip to main content

sley_sequencer/
lib.rs

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