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 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 let change_commit = commits.pop().unwrap();
123 let change_parent = change_commit.parent(0)?;
124
125 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 let change_diff = repo.diff_tree_to_tree(
146 Some(&change_parent.tree()?),
147 Some(&change_commit.tree()?),
148 None,
149 )?;
150
151 let mut current_base = repo.find_commit(base)?;
153
154 for (intermediate, change_id) in commits.iter().zip(intermediate_ids.iter()) {
155 let diff_applies = repo
157 .apply_to_tree(¤t_base.tree()?, &change_diff, None)
158 .is_ok();
159 if diff_applies && required.is_empty() {
160 break;
161 }
162
163 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(¤t_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 &[¤t_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 let mut index = repo.apply_to_tree(¤t_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 &[¤t_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}