1use anyhow::anyhow;
2
3#[derive(Debug, Clone)]
4struct Change {
5 pub author: String,
6 pub id: Option<String>,
7 pub series: Vec<String>,
8 pub commit: git2::Oid,
9}
10
11impl Change {
12 fn new(commit: git2::Oid) -> Self {
13 Self {
14 author: Default::default(),
15 id: Default::default(),
16 series: Default::default(),
17 commit,
18 }
19 }
20}
21
22fn is_trailer_line(line: &str) -> bool {
23 let key_len = line
24 .bytes()
25 .take_while(|&b| b.is_ascii_alphanumeric() || b == b'-')
26 .count();
27 key_len > 0 && line[key_len..].starts_with(": ")
28}
29
30pub fn commit_change_meta(commit: &git2::Commit) -> (Option<String>, Vec<String>) {
34 let (mut id, series) = parse_change_meta(commit.message().unwrap_or(""));
35 if let Ok(buf) = commit.header_field_bytes("change-id") {
36 if let Ok(s) = std::str::from_utf8(&buf) {
37 let s = s.trim();
38 if !s.is_empty() {
39 id = Some(s.to_string());
40 }
41 }
42 }
43 (id, series)
44}
45
46fn parse_change_meta(message: &str) -> (Option<String>, Vec<String>) {
47 let lines: Vec<&str> = message.lines().collect();
48 let mut footer_start = lines.len();
49 for (i, line) in lines.iter().enumerate().rev() {
50 if line.is_empty() || is_trailer_line(line) {
51 footer_start = i;
52 } else {
53 break;
54 }
55 }
56
57 let mut id: Option<String> = None;
58 let mut series: Vec<String> = Vec::new();
59 for line in &lines[footer_start..] {
60 if let Some(v) = line.strip_prefix("Change: ") {
61 id = Some(v.to_string());
62 }
63 if let Some(v) = line.strip_prefix("Change-Id: ") {
64 id = Some(v.to_string());
65 }
66 if let Some(v) = line.strip_prefix("Change-Series: ") {
67 series.push(v.to_string());
68 }
69 }
70 (id, series)
71}
72
73fn get_change_id(commit: &git2::Commit) -> Change {
74 let mut change = Change::new(commit.id());
75 change.author = commit.author().email().unwrap_or("").to_string();
76 let (id, series) = commit_change_meta(commit);
77 change.id = id;
78 change.series = series;
79 change
80}
81
82#[derive(PartialEq, Clone, Debug)]
83pub enum PushMode {
84 Normal,
85 Publish(String),
86}
87
88#[derive(Debug, Clone)]
89pub struct PushRef {
90 pub ref_name: String,
91 pub oid: git2::Oid,
92 pub change_id: String,
93}
94
95pub fn baseref_and_options(
96 refname: &str,
97 author: &str,
98) -> anyhow::Result<(String, String, Vec<String>, PushMode)> {
99 let mut split = refname.splitn(2, '%');
100 let push_to = split.next().ok_or(anyhow!("no next"))?.to_owned();
101
102 let options = if let Some(options) = split.next() {
103 options.split(',').map(|x| x.to_string()).collect()
104 } else {
105 vec![]
106 };
107
108 let mut baseref = push_to.to_owned();
109 let mut push_mode = PushMode::Normal;
110
111 if baseref.starts_with("refs/for") {
112 baseref = baseref.replacen("refs/for", "refs/heads", 1)
113 }
114 if baseref.starts_with("refs/drafts") {
115 baseref = baseref.replacen("refs/drafts", "refs/heads", 1)
116 }
117 if baseref.starts_with("refs/publish/for") {
118 push_mode = PushMode::Publish(author.to_string());
119 baseref = baseref.replacen("refs/publish/for", "refs/heads", 1)
120 }
121 Ok((baseref, push_to, options, push_mode))
122}
123
124fn split_changes(
125 repo: &git2::Repository,
126 changes: std::collections::HashMap<git2::Oid, Change>,
127 base: git2::Oid,
128) -> anyhow::Result<Vec<Change>> {
129 if base == git2::Oid::zero() {
130 return Ok(changes.into_values().collect());
131 }
132
133 changes
134 .iter()
135 .map(|(_, c)| downstack(repo, base, c))
136 .collect()
137}
138
139fn changed_paths(
140 repo: &git2::Repository,
141 commit: &git2::Commit,
142) -> anyhow::Result<std::collections::HashSet<String>> {
143 let parent_tree = if commit.parent_count() > 0 {
144 Some(commit.parent(0)?.tree()?)
145 } else {
146 None
147 };
148 let diff = repo.diff_tree_to_tree(parent_tree.as_ref(), Some(&commit.tree()?), None)?;
149 let mut paths = std::collections::HashSet::new();
150 for delta in diff.deltas() {
151 if let Some(p) = delta.old_file().path().and_then(|p| p.to_str()) {
152 paths.insert(p.to_string());
153 }
154 if let Some(p) = delta.new_file().path().and_then(|p| p.to_str()) {
155 paths.insert(p.to_string());
156 }
157 }
158 Ok(paths)
159}
160
161fn downstack(repo: &git2::Repository, base: git2::Oid, change: &Change) -> anyhow::Result<Change> {
162 let change_oid = change.commit;
163 if !repo.graph_descendant_of(change_oid, base)? {
164 return Err(anyhow!(
165 "change {} is not a descendant of base {}",
166 change_oid,
167 base
168 ));
169 }
170
171 let mut walk = repo.revwalk()?;
173 walk.simplify_first_parent()?;
174 walk.set_sorting(git2::Sort::REVERSE | git2::Sort::TOPOLOGICAL)?;
175 walk.push(change_oid)?;
176 walk.hide(base)?;
177
178 let oids: Vec<git2::Oid> = walk.collect::<Result<Vec<_>, _>>()?;
179
180 if oids.is_empty() {
181 return Ok(change.clone());
182 }
183
184 let mut commits: Vec<git2::Commit> = oids
185 .into_iter()
186 .map(|oid| repo.find_commit(oid))
187 .collect::<Result<Vec<_>, _>>()?;
188
189 let change_commit = commits.pop().unwrap();
191 let change_parent = change_commit.parent(0)?;
192
193 let change_meta = get_change_id(&change_commit);
196 let mut affected_paths = changed_paths(repo, &change_commit)?;
197 for s in &change_meta.series {
198 affected_paths.insert(format!("\x00series:{}", s));
199 }
200 let mut needed: Vec<bool> = vec![false; commits.len()];
201 for (i, intermediate) in commits.iter().enumerate().rev() {
202 let meta = get_change_id(intermediate);
203 let mut paths = changed_paths(repo, intermediate)?;
204 for s in &meta.series {
205 paths.insert(format!("\x00series:{}", s));
206 }
207 if !paths.is_disjoint(&affected_paths) {
208 needed[i] = true;
209 affected_paths.extend(paths);
210 }
211 }
212
213 let mut current_base = repo.find_commit(base)?;
215 for (intermediate, is_needed) in commits.iter().zip(needed.iter()) {
216 if !is_needed {
217 continue;
218 }
219 let inter_parent = intermediate.parent(0)?;
220 let mut index = repo.merge_trees(
221 &inter_parent.tree()?,
222 ¤t_base.tree()?,
223 &intermediate.tree()?,
224 None,
225 )?;
226 let new_tree = repo.find_tree(index.write_tree_to(repo)?)?;
227 let new_oid = josh_core::history::rewrite_commit(
228 repo,
229 intermediate,
230 &[¤t_base],
231 josh_core::filter::Rewrite::from_tree(new_tree),
232 josh_core::history::GpgsigMode::Preserve,
233 )?;
234 current_base = repo.find_commit(new_oid)?;
235 }
236
237 let mut index = repo.merge_trees(
239 &change_parent.tree()?,
240 ¤t_base.tree()?,
241 &change_commit.tree()?,
242 None,
243 )?;
244 let new_tree = repo.find_tree(index.write_tree_to(repo)?)?;
245
246 let new_oid = josh_core::history::rewrite_commit(
247 repo,
248 &change_commit,
249 &[¤t_base],
250 josh_core::filter::Rewrite::from_tree(new_tree),
251 josh_core::history::GpgsigMode::Preserve,
252 )?;
253
254 let mut result = change.clone();
255 result.commit = new_oid;
256 Ok(result)
257}
258
259fn changes_to_refs(
260 repo: &git2::Repository,
261 baseref: &str,
262 change_author: &str,
263 changes: Vec<Change>,
264) -> anyhow::Result<Vec<PushRef>> {
265 if !change_author.contains('@') {
266 return Err(anyhow!(
267 "Push option 'author' needs to be set to a valid email address",
268 ));
269 };
270
271 let changes: Vec<Change> = changes
272 .into_iter()
273 .filter(|change| change.author == change_author)
274 .collect();
275
276 let mut seen = std::collections::HashSet::new();
277 for change in changes.iter() {
278 if let Some(id) = &change.id {
279 if id.contains('@') {
280 return Err(anyhow!("Change id must not contain '@'"));
281 }
282 if !seen.insert(id) {
283 return Err(anyhow!(
284 "rejecting to push {:?} with duplicate label",
285 change.commit
286 ));
287 }
288 seen.insert(id);
289 }
290 }
291
292 let mut refs = vec![];
293 for change in changes {
294 if let Some(change_id) = change.id {
295 let ref_name = format!(
296 "refs/heads/@changes/{}/{}/{}",
297 baseref.replacen("refs/heads/", "", 1),
298 change.author,
299 change_id,
300 );
301 let base_ref_name = ref_name.replacen("refs/heads/@changes", "refs/heads/@base", 1);
302 refs.push(PushRef {
303 ref_name,
304 oid: change.commit,
305 change_id: change_id.clone(),
306 });
307 if let Some(parent_sha) = repo.find_commit(change.commit)?.parent_ids().next() {
308 refs.push(PushRef {
309 ref_name: base_ref_name,
310 oid: parent_sha,
311 change_id,
312 });
313 }
314 }
315 }
316 Ok(refs)
317}
318
319fn get_changes(
320 repo: &git2::Repository,
321 tip: git2::Oid,
322 base: git2::Oid,
323) -> anyhow::Result<std::collections::HashMap<git2::Oid, Change>> {
324 let mut walk = repo.revwalk()?;
325 walk.set_sorting(git2::Sort::REVERSE | git2::Sort::TOPOLOGICAL)?;
326 walk.simplify_first_parent()?;
327 walk.push(tip)?;
328 if base != git2::Oid::zero() {
329 walk.hide(base)?;
330 }
331
332 let mut changes = std::collections::HashMap::new();
333 for rev in walk {
334 let commit = repo.find_commit(rev?)?;
335 let change = get_change_id(&commit);
336 changes.insert(change.commit, change);
337 }
338
339 Ok(changes)
340}
341
342pub fn build_to_push(
343 repo: &git2::Repository,
344 push_mode: &PushMode,
345 baseref: &str,
346 ref_with_options: &str,
347 oid_to_push: git2::Oid,
348 base_oid: git2::Oid,
349) -> anyhow::Result<Vec<PushRef>> {
350 match push_mode {
351 PushMode::Publish(author) => {
352 let changes = get_changes(repo, oid_to_push, base_oid)?;
353 let changes = split_changes(repo, changes, base_oid)?;
354
355 let mut push_refs = changes_to_refs(repo, baseref, author, changes)?;
356
357 push_refs.push(PushRef {
358 ref_name: format!(
359 "refs/heads/@heads/{}/{}",
360 baseref.replacen("refs/heads/", "", 1),
361 author,
362 ),
363 oid: oid_to_push,
364 change_id: baseref.replacen("refs/heads/", "", 1),
365 });
366
367 push_refs.sort_by(|a, b| a.ref_name.cmp(&b.ref_name));
368 Ok(push_refs)
369 }
370 PushMode::Normal => Ok(vec![PushRef {
371 ref_name: if ref_with_options.starts_with("refs/") {
372 ref_with_options.to_string()
373 } else {
374 format!("refs/heads/{}", ref_with_options)
375 },
376 oid: oid_to_push,
377 change_id: "JOSH_PUSH".to_string(),
378 }]),
379 }
380}
381
382#[cfg(test)]
383mod tests {
384 use super::*;
385
386 #[test]
387 fn footer_in_body_is_ignored() {
388 let (id, series) =
389 parse_change_meta("Subject\n\nbody mentions Change: not-a-trailer\nmore body\n");
390 assert_eq!(id, None);
391 assert!(series.is_empty());
392 }
393
394 #[test]
395 fn real_trailing_footer_is_parsed() {
396 let (id, _) = parse_change_meta("Subject\n\nBody.\n\nChange: real-id\n");
397 assert_eq!(id.as_deref(), Some("real-id"));
398 }
399
400 #[test]
401 fn single_line_message_is_its_own_footer() {
402 let (id, _) = parse_change_meta("Change: only-line");
403 assert_eq!(id.as_deref(), Some("only-line"));
404 }
405
406 #[test]
407 fn footer_followed_by_body_is_ignored() {
408 let (id, _) = parse_change_meta("Subject\n\nChange: middle\n\nBody after.\n");
409 assert_eq!(id, None);
410 }
411
412 #[test]
413 fn other_trailers_in_block_do_not_break_change() {
414 let msg = "Subject\n\nBody.\n\nSigned-off-by: x <x@y>\nChange: real\n\
415 Reviewed-by: z <z@w>\n";
416 let (id, _) = parse_change_meta(msg);
417 assert_eq!(id.as_deref(), Some("real"));
418 }
419
420 #[test]
421 fn series_in_footer_block_is_collected() {
422 let msg = "Subject\n\nBody.\n\nChange-Series: s1\nChange-Series: s2\nChange: c\n";
423 let (id, series) = parse_change_meta(msg);
424 assert_eq!(id.as_deref(), Some("c"));
425 assert_eq!(series, vec!["s1".to_string(), "s2".to_string()]);
426 }
427
428 #[test]
429 fn series_in_body_is_ignored() {
430 let msg = "Subject\n\nWe discussed Change-Series: bogus here.\nmore body\n";
431 let (_id, series) = parse_change_meta(msg);
432 assert!(series.is_empty());
433 }
434
435 #[test]
436 fn is_trailer_line_basics() {
437 assert!(is_trailer_line("Change: foo"));
438 assert!(is_trailer_line("Change-Id: foo"));
439 assert!(is_trailer_line("Signed-off-by: a <a@b>"));
440 assert!(!is_trailer_line("not a trailer"));
441 assert!(!is_trailer_line("Change:no-space"));
442 assert!(!is_trailer_line(": leading colon"));
443 assert!(!is_trailer_line(""));
444 }
445}