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 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 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 mut tx = refs.transaction();
254 tx.update(RefUpdate {
255 name: updated_ref.clone(),
256 expected,
257 new: RefTarget::Direct(oid),
258 reflog: Some(ReflogEntry {
259 old_oid,
260 new_oid: oid,
261 committer: options.committer,
262 message: options.reflog_message,
263 }),
264 });
265 tx.commit()?;
266 Ok(CommitIndexResult {
267 oid,
268 tree,
269 updated_ref,
270 parent,
271 })
272}
273
274pub fn format_commit_identity(name: &str, email: &str, date: &str) -> Result<Vec<u8>> {
275 validate_identity_component("name", name)?;
276 validate_identity_component("email", email)?;
277 let (seconds, timezone) = parse_raw_git_date(date)?;
278 Ok(format!("{name} <{email}> {seconds} {timezone}").into_bytes())
279}
280
281pub fn commit_message_from_chunks(chunks: &[Vec<u8>]) -> Vec<u8> {
282 let mut out = Vec::new();
283 for (idx, chunk) in chunks.iter().enumerate() {
284 if idx != 0 {
285 out.push(b'\n');
286 }
287 out.extend_from_slice(chunk);
288 out.push(b'\n');
289 }
290 out
291}
292
293fn head_update_target(refs: &FileRefStore) -> Result<(String, Option<ObjectId>)> {
294 match refs.read_ref("HEAD")? {
295 Some(RefTarget::Symbolic(name)) => match refs.read_ref(&name)? {
296 Some(RefTarget::Direct(oid)) => Ok((name, Some(oid))),
297 Some(RefTarget::Symbolic(_)) => Err(GitError::InvalidFormat(
298 "nested symbolic HEAD target is unsupported".into(),
299 )),
300 None => Ok((name, None)),
301 },
302 Some(RefTarget::Direct(oid)) => Ok(("HEAD".into(), Some(oid))),
303 None => Ok(("HEAD".into(), None)),
304 }
305}
306
307fn zero_oid(format: sley_core::ObjectFormat) -> Result<ObjectId> {
308 Ok(ObjectId::null(format))
309}
310
311fn validate_identity_component(name: &str, value: &str) -> Result<()> {
312 if value.bytes().any(|byte| matches!(byte, b'\n' | b'\r' | 0)) {
313 return Err(GitError::InvalidFormat(format!(
314 "commit identity {name} contains a control byte"
315 )));
316 }
317 Ok(())
318}
319
320fn parse_raw_git_date(date: &str) -> Result<(i64, String)> {
321 let mut parts = date.split_whitespace();
322 let seconds = parts
323 .next()
324 .ok_or_else(|| GitError::InvalidFormat("missing commit date seconds".into()))?;
325 let timezone = parts
326 .next()
327 .ok_or_else(|| GitError::InvalidFormat("missing commit date timezone".into()))?;
328 if parts.next().is_some() {
329 return Err(GitError::InvalidFormat(
330 "commit date has trailing fields".into(),
331 ));
332 }
333 let seconds = seconds.strip_prefix('@').unwrap_or(seconds);
334 let seconds = seconds
335 .parse::<i64>()
336 .map_err(|_| GitError::InvalidFormat("invalid commit date seconds".into()))?;
337 validate_timezone(timezone)?;
338 Ok((seconds, timezone.to_string()))
339}
340
341fn validate_timezone(timezone: &str) -> Result<()> {
342 let bytes = timezone.as_bytes();
343 if bytes.len() != 5
344 || !matches!(bytes[0], b'+' | b'-')
345 || !bytes[1..].iter().all(u8::is_ascii_digit)
346 {
347 return Err(GitError::InvalidFormat(format!(
348 "invalid commit timezone {timezone}"
349 )));
350 }
351 Ok(())
352}
353
354#[cfg(test)]
355mod tests {
356 use super::*;
357 use sley_core::ObjectFormat;
358 use sley_odb::ObjectDatabase;
359
360 #[test]
361 fn commit_identity_formats_raw_git_date() {
362 let identity =
363 format_commit_identity("Example User", "example@example.invalid", "@0 +0000")
364 .expect("test operation should succeed");
365 assert_eq!(identity, b"Example User <example@example.invalid> 0 +0000");
366 }
367
368 #[test]
369 fn create_commit_writes_commit_object() {
370 let tree = ObjectId::from_hex(
371 ObjectFormat::Sha1,
372 "4b825dc642cb6eb9a060e54bf8d69288fbee4904",
373 )
374 .expect("test operation should succeed");
375 let identity =
376 format_commit_identity("Example User", "example@example.invalid", "@0 +0000")
377 .expect("test operation should succeed");
378 let mut db = ObjectDatabase::new(ObjectFormat::Sha1);
379 let oid = create_commit(
380 &mut db,
381 CommitCreate {
382 tree,
383 parents: Vec::new(),
384 author: identity.clone(),
385 committer: identity,
386 message: b"initial subject\n".to_vec(),
387 encoding: None,
388 signature: None,
389 },
390 )
391 .expect("test operation should succeed");
392 assert_eq!(oid.to_hex(), "e7556fb3ba7b8f5b1f4772180772a4d6a7323e15");
393 }
394
395 #[test]
396 fn create_annotated_tag_writes_tag_object() {
397 let target = ObjectId::from_hex(
398 ObjectFormat::Sha1,
399 "e7556fb3ba7b8f5b1f4772180772a4d6a7323e15",
400 )
401 .expect("test operation should succeed");
402 let tagger = format_commit_identity("Example User", "example@example.invalid", "@0 +0000")
403 .expect("test operation should succeed");
404 let mut db = ObjectDatabase::new(ObjectFormat::Sha1);
405 let oid = create_annotated_tag(
406 &mut db,
407 TagCreate {
408 object: target,
409 object_type: ObjectType::Commit,
410 name: b"v1.0".to_vec(),
411 tagger,
412 message: b"release\n".to_vec(),
413 },
414 )
415 .expect("test operation should succeed");
416 assert_eq!(oid.to_hex(), "b9c6a18e58a4efa0a5c023bcf0d8f2a320ae4098");
417 }
418}