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