Skip to main content

josh_changes/
lib.rs

1use anyhow::anyhow;
2use josh_core::Change;
3
4#[derive(PartialEq, Clone, Debug)]
5pub enum PushMode {
6    Normal,
7    Stack(String),
8    Split(String),
9}
10
11#[derive(Debug, Clone)]
12pub struct PushRef {
13    pub ref_name: String,
14    pub oid: git2::Oid,
15    pub change_id: String,
16}
17
18pub fn baseref_and_options(
19    refname: &str,
20    author: &str,
21) -> anyhow::Result<(String, String, Vec<String>, PushMode)> {
22    let mut split = refname.splitn(2, '%');
23    let push_to = split.next().ok_or(anyhow!("no next"))?.to_owned();
24
25    let options = if let Some(options) = split.next() {
26        options.split(',').map(|x| x.to_string()).collect()
27    } else {
28        vec![]
29    };
30
31    let mut baseref = push_to.to_owned();
32    let mut push_mode = PushMode::Normal;
33
34    if baseref.starts_with("refs/for") {
35        baseref = baseref.replacen("refs/for", "refs/heads", 1)
36    }
37    if baseref.starts_with("refs/drafts") {
38        baseref = baseref.replacen("refs/drafts", "refs/heads", 1)
39    }
40    if baseref.starts_with("refs/stack/for") {
41        push_mode = PushMode::Stack(author.to_string());
42        baseref = baseref.replacen("refs/stack/for", "refs/heads", 1)
43    }
44    if baseref.starts_with("refs/split/for") {
45        push_mode = PushMode::Split(author.to_string());
46        baseref = baseref.replacen("refs/split/for", "refs/heads", 1)
47    }
48    Ok((baseref, push_to, options, push_mode))
49}
50
51fn add_base_refs(repo: &git2::Repository, refs: &mut Vec<PushRef>) -> anyhow::Result<()> {
52    let original_refs = std::mem::take(refs);
53    for push_ref in original_refs.into_iter() {
54        let base_ref = push_ref
55            .ref_name
56            .replacen("refs/heads/@changes", "refs/heads/@base", 1);
57
58        let oid = push_ref.oid;
59        let change_id = push_ref.change_id.clone();
60        refs.push(push_ref);
61
62        if let Some(parent_sha) = repo.find_commit(oid)?.parent_ids().next() {
63            refs.push(PushRef {
64                ref_name: base_ref,
65                oid: parent_sha,
66                change_id,
67            });
68        }
69    }
70
71    Ok(())
72}
73
74fn split_changes(
75    repo: &git2::Repository,
76    changes: &mut [PushRef],
77    base: git2::Oid,
78) -> anyhow::Result<()> {
79    if base == git2::Oid::zero() {
80        return Ok(());
81    }
82
83    for push_ref in changes.iter_mut() {
84        push_ref.oid = downstack(repo, base, push_ref.oid)?;
85    }
86
87    Ok(())
88}
89
90pub fn downstack(
91    repo: &git2::Repository,
92    base: git2::Oid,
93    change: git2::Oid,
94) -> anyhow::Result<git2::Oid> {
95    if !repo.graph_descendant_of(change, base)? {
96        return Err(anyhow!(
97            "change {} is not a descendant of base {}",
98            change,
99            base
100        ));
101    }
102
103    // Collect commits from base to change (exclusive of base, inclusive of change)
104    let mut walk = repo.revwalk()?;
105    walk.simplify_first_parent()?;
106    walk.set_sorting(git2::Sort::REVERSE | git2::Sort::TOPOLOGICAL)?;
107    walk.push(change)?;
108    walk.hide(base)?;
109
110    let oids: Vec<git2::Oid> = walk.collect::<Result<Vec<_>, _>>()?;
111
112    if oids.is_empty() {
113        return Ok(change);
114    }
115
116    let mut commits: Vec<git2::Commit> = oids
117        .into_iter()
118        .map(|oid| repo.find_commit(oid))
119        .collect::<Result<Vec<_>, _>>()?;
120
121    // The last commit is `change`; split it off from the intermediates
122    let change_commit = commits.pop().unwrap();
123    let change_parent = change_commit.parent(0)?;
124
125    // Parse Requires: footers, keeping only those referencing changes
126    // actually present in the intermediates
127    let required_raw: std::collections::HashSet<String> = josh_core::get_change_id(&change_commit)
128        .requires
129        .into_iter()
130        .collect();
131    let intermediate_ids: Vec<Option<String>> = commits
132        .iter()
133        .map(|c| josh_core::get_change_id(c).id)
134        .collect();
135    let available_ids: std::collections::HashSet<&str> = intermediate_ids
136        .iter()
137        .filter_map(|id| id.as_deref())
138        .collect();
139    let mut required: std::collections::HashSet<String> = required_raw
140        .into_iter()
141        .filter(|id| available_ids.contains(id.as_str()))
142        .collect();
143
144    // Compute d_change: the diff introduced by the change commit itself
145    let change_diff = repo.diff_tree_to_tree(
146        Some(&change_parent.tree()?),
147        Some(&change_commit.tree()?),
148        None,
149    )?;
150
151    // Walk through intermediates, including only those needed for d_change to apply
152    let mut current_base = repo.find_commit(base)?;
153
154    for (intermediate, change_id) in commits.iter().zip(intermediate_ids.iter()) {
155        // Stop when d_change applies and all Requires: are satisfied
156        let diff_applies = repo
157            .apply_to_tree(&current_base.tree()?, &change_diff, None)
158            .is_ok();
159        if diff_applies && required.is_empty() {
160            break;
161        }
162
163        // d_change does not apply yet; we need this intermediate commit.
164        // Rebase it onto current_base by applying its diff.
165        let inter_parent = intermediate.parent(0)?;
166        let inter_diff = repo.diff_tree_to_tree(
167            Some(&inter_parent.tree()?),
168            Some(&intermediate.tree()?),
169            None,
170        )?;
171
172        let mut index = repo.apply_to_tree(&current_base.tree()?, &inter_diff, None)?;
173        let new_tree = repo.find_tree(index.write_tree_to(repo)?)?;
174
175        let new_oid = josh_core::history::rewrite_commit(
176            repo,
177            intermediate,
178            &[&current_base],
179            josh_core::filter::Rewrite::from_tree(new_tree),
180            josh_core::history::GpgsigMode::Preserve,
181        )?;
182        current_base = repo.find_commit(new_oid)?;
183
184        if let Some(id) = change_id {
185            required.remove(id);
186        }
187    }
188
189    // Apply d_change on top of the minimal base and create the new change commit
190    let mut index = repo.apply_to_tree(&current_base.tree()?, &change_diff, None)?;
191    let new_tree = repo.find_tree(index.write_tree_to(repo)?)?;
192
193    josh_core::history::rewrite_commit(
194        repo,
195        &change_commit,
196        &[&current_base],
197        josh_core::filter::Rewrite::from_tree(new_tree),
198        josh_core::history::GpgsigMode::Preserve,
199    )
200}
201
202pub fn changes_to_refs(
203    baseref: &str,
204    change_author: &str,
205    changes: Vec<Change>,
206) -> anyhow::Result<Vec<PushRef>> {
207    if !change_author.contains('@') {
208        return Err(anyhow!(
209            "Push option 'author' needs to be set to a valid email address",
210        ));
211    };
212
213    let changes: Vec<Change> = changes
214        .into_iter()
215        .filter(|change| change.author == change_author)
216        .collect();
217
218    let mut seen = std::collections::HashSet::new();
219    for change in changes.iter() {
220        if let Some(id) = &change.id {
221            if id.contains('@') {
222                return Err(anyhow!("Change id must not contain '@'"));
223            }
224            if !seen.insert(id) {
225                return Err(anyhow!(
226                    "rejecting to push {:?} with duplicate label",
227                    change.commit
228                ));
229            }
230            seen.insert(id);
231        }
232    }
233
234    Ok(changes
235        .into_iter()
236        .filter_map(|change| {
237            change.id.map(|change_id| PushRef {
238                ref_name: format!(
239                    "refs/heads/@changes/{}/{}/{}",
240                    baseref.replacen("refs/heads/", "", 1),
241                    change.author,
242                    change_id,
243                ),
244                oid: change.commit,
245                change_id,
246            })
247        })
248        .collect())
249}
250
251fn get_changes(
252    repo: &git2::Repository,
253    tip: git2::Oid,
254    base: git2::Oid,
255) -> anyhow::Result<Vec<Change>> {
256    let mut walk = repo.revwalk()?;
257    walk.set_sorting(git2::Sort::REVERSE | git2::Sort::TOPOLOGICAL)?;
258    walk.simplify_first_parent()?;
259    walk.push(tip)?;
260    if base != git2::Oid::zero() {
261        walk.hide(base)?;
262    }
263
264    let mut changes = vec![];
265    for rev in walk {
266        let commit = repo.find_commit(rev?)?;
267        changes.push(josh_core::get_change_id(&commit));
268    }
269
270    Ok(changes)
271}
272
273pub fn build_to_push(
274    repo: &git2::Repository,
275    push_mode: &PushMode,
276    baseref: &str,
277    ref_with_options: &str,
278    oid_to_push: git2::Oid,
279    base_oid: git2::Oid,
280) -> anyhow::Result<Vec<PushRef>> {
281    match push_mode {
282        PushMode::Stack(author) | PushMode::Split(author) => {
283            let changes = get_changes(repo, oid_to_push, base_oid)?;
284            let mut push_refs = changes_to_refs(baseref, author, changes)?;
285
286            if matches!(push_mode, PushMode::Split(_)) {
287                split_changes(repo, &mut push_refs, base_oid)?;
288            }
289
290            add_base_refs(repo, &mut push_refs)?;
291
292            push_refs.push(PushRef {
293                ref_name: format!(
294                    "refs/heads/@heads/{}/{}",
295                    baseref.replacen("refs/heads/", "", 1),
296                    author,
297                ),
298                oid: oid_to_push,
299                change_id: baseref.replacen("refs/heads/", "", 1),
300            });
301
302            Ok(push_refs)
303        }
304        PushMode::Normal => Ok(vec![PushRef {
305            ref_name: if ref_with_options.starts_with("refs/") {
306                ref_with_options.to_string()
307            } else {
308                format!("refs/heads/{}", ref_with_options)
309            },
310            oid: oid_to_push,
311            change_id: "JOSH_PUSH".to_string(),
312        }]),
313    }
314}