Skip to main content

josh_changes/
lib.rs

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
30/// Extract change-id metadata from a commit, preferring jj/gitbutler's custom
31/// `change-id` commit-object header over any `Change:` / `Change-Id:` trailer
32/// in the message body. The series list comes from message trailers regardless.
33pub 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    // Collect commits from base to change (exclusive of base, inclusive of change)
172    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    // The last commit is `change`; split it off from the intermediates
190    let change_commit = commits.pop().unwrap();
191    let change_parent = change_commit.parent(0)?;
192
193    // Seed the affected path set with the change's own modified paths, then
194    // walk intermediates backwards, keeping any commit whose paths intersect.
195    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    // Rebase needed intermediates forward onto current_base.
214    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            &current_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            &[&current_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    // Apply the change on top of the minimal base via 3-way merge.
238    let mut index = repo.merge_trees(
239        &change_parent.tree()?,
240        &current_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        &[&current_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}