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    // Compute d_change: the diff introduced by the change commit itself
126    let change_diff = repo.diff_tree_to_tree(
127        Some(&change_parent.tree()?),
128        Some(&change_commit.tree()?),
129        None,
130    )?;
131
132    // Walk through intermediates, including only those needed for d_change to apply
133    let mut current_base = repo.find_commit(base)?;
134
135    for intermediate in &commits {
136        // If d_change already applies to the current base tree, we can stop
137        if repo
138            .apply_to_tree(&current_base.tree()?, &change_diff, None)
139            .is_ok()
140        {
141            break;
142        }
143
144        // d_change does not apply yet; we need this intermediate commit.
145        // Rebase it onto current_base by applying its diff.
146        let inter_parent = intermediate.parent(0)?;
147        let inter_diff = repo.diff_tree_to_tree(
148            Some(&inter_parent.tree()?),
149            Some(&intermediate.tree()?),
150            None,
151        )?;
152
153        let mut index = repo.apply_to_tree(&current_base.tree()?, &inter_diff, None)?;
154        let new_tree = repo.find_tree(index.write_tree_to(repo)?)?;
155
156        let new_oid = josh_core::history::rewrite_commit(
157            repo,
158            intermediate,
159            &[&current_base],
160            josh_core::filter::Rewrite::from_tree(new_tree),
161            josh_core::history::GpgsigMode::Preserve,
162        )?;
163        current_base = repo.find_commit(new_oid)?;
164    }
165
166    // Apply d_change on top of the minimal base and create the new change commit
167    let mut index = repo.apply_to_tree(&current_base.tree()?, &change_diff, None)?;
168    let new_tree = repo.find_tree(index.write_tree_to(repo)?)?;
169
170    josh_core::history::rewrite_commit(
171        repo,
172        &change_commit,
173        &[&current_base],
174        josh_core::filter::Rewrite::from_tree(new_tree),
175        josh_core::history::GpgsigMode::Preserve,
176    )
177}
178
179pub fn changes_to_refs(
180    baseref: &str,
181    change_author: &str,
182    changes: Vec<Change>,
183) -> anyhow::Result<Vec<PushRef>> {
184    if !change_author.contains('@') {
185        return Err(anyhow!(
186            "Push option 'author' needs to be set to a valid email address",
187        ));
188    };
189
190    let changes: Vec<Change> = changes
191        .into_iter()
192        .filter(|change| change.author == change_author)
193        .collect();
194
195    let mut seen = std::collections::HashSet::new();
196    for change in changes.iter() {
197        if let Some(id) = &change.id {
198            if id.contains('@') {
199                return Err(anyhow!("Change id must not contain '@'"));
200            }
201            if !seen.insert(id) {
202                return Err(anyhow!(
203                    "rejecting to push {:?} with duplicate label",
204                    change.commit
205                ));
206            }
207            seen.insert(id);
208        }
209    }
210
211    Ok(changes
212        .into_iter()
213        .filter_map(|change| {
214            change.id.map(|change_id| PushRef {
215                ref_name: format!(
216                    "refs/heads/@changes/{}/{}/{}",
217                    baseref.replacen("refs/heads/", "", 1),
218                    change.author,
219                    change_id,
220                ),
221                oid: change.commit,
222                change_id,
223            })
224        })
225        .collect())
226}
227
228fn get_changes(
229    repo: &git2::Repository,
230    tip: git2::Oid,
231    base: git2::Oid,
232) -> anyhow::Result<Vec<Change>> {
233    let mut walk = repo.revwalk()?;
234    walk.set_sorting(git2::Sort::REVERSE | git2::Sort::TOPOLOGICAL)?;
235    walk.simplify_first_parent()?;
236    walk.push(tip)?;
237    if base != git2::Oid::zero() {
238        walk.hide(base)?;
239    }
240
241    let mut changes = vec![];
242    for rev in walk {
243        let commit = repo.find_commit(rev?)?;
244        changes.push(josh_core::get_change_id(&commit));
245    }
246
247    Ok(changes)
248}
249
250pub fn build_to_push(
251    repo: &git2::Repository,
252    push_mode: &PushMode,
253    baseref: &str,
254    ref_with_options: &str,
255    oid_to_push: git2::Oid,
256    base_oid: git2::Oid,
257) -> anyhow::Result<Vec<PushRef>> {
258    match push_mode {
259        PushMode::Stack(author) | PushMode::Split(author) => {
260            let changes = get_changes(repo, oid_to_push, base_oid)?;
261            let mut push_refs = changes_to_refs(baseref, author, changes)?;
262
263            if matches!(push_mode, PushMode::Split(_)) {
264                split_changes(repo, &mut push_refs, base_oid)?;
265            }
266
267            add_base_refs(repo, &mut push_refs)?;
268
269            push_refs.push(PushRef {
270                ref_name: format!(
271                    "refs/heads/@heads/{}/{}",
272                    baseref.replacen("refs/heads/", "", 1),
273                    author,
274                ),
275                oid: oid_to_push,
276                change_id: baseref.replacen("refs/heads/", "", 1),
277            });
278
279            Ok(push_refs)
280        }
281        PushMode::Normal => Ok(vec![PushRef {
282            ref_name: ref_with_options.to_string(),
283            oid: oid_to_push,
284            change_id: "JOSH_PUSH".to_string(),
285        }]),
286    }
287}