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 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 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}