1use std::sync::OnceLock;
23
24use async_trait::async_trait;
25
26use crate::compare::{parse_head_spec, valid_ref, valid_sha, HeadAccess};
27use crate::error::ForgeError;
28use crate::html::agent_label;
29use crate::prgit;
30use crate::pulls::{self, MergeRecord, PullEntry};
31use crate::repo;
32use crate::spine::issues::IssueState;
33use crate::{ForgeAgent, ForgeResponse, ForgeService};
34
35#[async_trait]
44pub trait TipRecorder: Send + Sync {
45 async fn record_tip(&self, owner: &str, repo: &str);
47}
48
49#[derive(Debug, Clone, Copy, Default)]
51pub struct NoopTipRecorder;
52
53#[async_trait]
54impl TipRecorder for NoopTipRecorder {
55 async fn record_tip(&self, _owner: &str, _repo: &str) {}
56}
57
58#[must_use]
60pub fn parse_git_version(s: &str) -> Option<(u32, u32)> {
61 let v = s.trim().strip_prefix("git version ").unwrap_or(s.trim());
62 let mut it = v.split(|c: char| !c.is_ascii_digit());
63 let major = it.next()?.parse().ok()?;
64 let minor = it.next()?.parse().ok()?;
65 Some((major, minor))
66}
67
68#[must_use]
70pub fn merge_tree_supported(version: &str) -> bool {
71 matches!(parse_git_version(version), Some(v) if v >= (2, 38))
72}
73
74#[must_use]
76pub fn detected_git_version() -> &'static str {
77 static V: OnceLock<String> = OnceLock::new();
78 V.get_or_init(|| {
79 solid_pod_rs_git::hardened_git()
80 .arg("--version")
81 .output()
82 .ok()
83 .map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
84 .filter(|s| !s.is_empty())
85 .unwrap_or_else(|| "unknown".to_string())
86 })
87}
88
89#[derive(Debug, Clone, PartialEq, Eq)]
91pub struct MergeTree {
92 pub clean: bool,
94 pub tree: Option<String>,
96 pub conflicts: Vec<String>,
98}
99
100#[must_use]
103pub fn parse_merge_tree(stdout: &str, clean: bool) -> MergeTree {
104 let mut it = stdout.split('\0');
105 let tree = it.next().unwrap_or("").trim().to_string();
106 let mut conflicts: Vec<String> = Vec::new();
107 if !clean {
108 for f in it {
109 if f.is_empty() {
110 break;
111 }
112 if !conflicts.iter().any(|c| c == f) {
113 conflicts.push(f.to_string());
114 }
115 }
116 }
117 MergeTree {
118 clean,
119 tree: clean.then_some(tree),
120 conflicts,
121 }
122}
123
124pub(crate) async fn merge_tree_of(
128 dir: &std::path::Path,
129 base_sha: &str,
130 head_sha: &str,
131) -> Result<MergeTree, ForgeError> {
132 if !valid_sha(base_sha) || !valid_sha(head_sha) {
133 return Err(ForgeError::BadRequest(
134 "merge ends must be object ids".into(),
135 ));
136 }
137 let out = prgit::run(
138 dir,
139 &[
140 "merge-tree",
141 "--write-tree",
142 "--name-only",
143 "-z",
144 base_sha,
145 head_sha,
146 ],
147 )
148 .await?;
149 match out.code {
150 Some(0) => Ok(parse_merge_tree(&out.text(), true)),
151 Some(1) => Ok(parse_merge_tree(&out.text(), false)),
152 _ => Err(ForgeError::Backend(format!(
153 "git merge-tree: {}",
154 out.stderr.trim()
155 ))),
156 }
157}
158
159#[derive(Debug, Clone, Default)]
161pub struct MergeInfo {
162 pub base_sha: Option<String>,
164 pub head_sha: Option<String>,
166 pub mergeable: Option<bool>,
168 pub conflicts: Vec<String>,
170 pub fast_forward: bool,
172 pub up_to_date: bool,
174}
175
176fn ident(s: &str) -> String {
178 let t: String = s
179 .chars()
180 .filter(|c| !c.is_control() && *c != '<' && *c != '>')
181 .collect();
182 let t = t.trim();
183 if t.is_empty() {
184 "agent".to_string()
185 } else {
186 t.to_string()
187 }
188}
189
190fn json(status: u16, v: &serde_json::Value) -> ForgeResponse {
191 ForgeResponse::json(status, v)
192}
193
194impl ForgeService {
195 #[must_use]
197 pub fn merge_tree_ok(&self) -> bool {
198 self.pr.merge_tree_ok
199 }
200
201 #[must_use]
205 pub fn with_git_version(mut self, version: &str) -> Self {
206 self.pr.merge_tree_ok = merge_tree_supported(version);
207 self.pr.git_version = version.to_string();
208 self
209 }
210
211 #[must_use]
213 pub fn with_tip_recorder(mut self, rec: std::sync::Arc<dyn TipRecorder>) -> Self {
214 self.pr.tip = rec;
215 self
216 }
217
218 pub(crate) async fn pull_merge_info(
220 &self,
221 owner: &str,
222 name: &str,
223 pr: &PullEntry,
224 ) -> Result<MergeInfo, ForgeError> {
225 let dir = repo::repo_git_dir(&self.repo_root, owner, name)?;
226 let mut info = MergeInfo::default();
227 if !valid_ref(&pr.base) {
228 return Ok(info);
229 }
230 info.base_sha = prgit::rev_parse(&dir, &format!("refs/heads/{}", pr.base)).await;
231 let access = if pr.state == crate::spine::issues::IssueState::Open {
232 HeadAccess::ExistingPr
233 } else {
234 HeadAccess::Cached
235 };
236 let head = self
237 .resolve_head(owner, name, &pr.head_spec(), access)
238 .await?;
239 info.head_sha = head.as_ref().map(|h| h.sha.clone());
240 let (Some(base), Some(head)) = (info.base_sha.clone(), head) else {
241 return Ok(info);
242 };
243 if prgit::is_ancestor(&dir, &head.sha, &base).await {
244 info.mergeable = Some(false);
245 info.up_to_date = true;
246 return Ok(info);
247 }
248 if prgit::is_ancestor(&dir, &base, &head.sha).await {
249 info.mergeable = Some(true);
250 info.fast_forward = true;
251 return Ok(info);
252 }
253 if !self.pr.merge_tree_ok {
254 return Ok(info);
255 }
256 if let Ok(mt) = merge_tree_of(&dir, &base, &head.sha).await {
257 info.mergeable = Some(mt.clean);
258 info.conflicts = mt.conflicts;
259 }
260 Ok(info)
261 }
262
263 pub(crate) async fn merge_pull(
273 &self,
274 agent: &ForgeAgent,
275 owner: &str,
276 name: &str,
277 number: u64,
278 expected_base: Option<&str>,
279 ) -> Result<ForgeResponse, ForgeError> {
280 let Some(who) = agent.owner() else {
281 return Ok(ForgeResponse::error(401, "authentication required"));
282 };
283 if who != owner {
284 return Ok(ForgeResponse::error(403, "only the repo owner may merge"));
285 }
286 if !self.pr.merge_tree_ok {
287 return Ok(ForgeResponse::error(
288 501,
289 format!(
290 "merging needs git >= 2.38 (merge-tree --write-tree); this server has \"{}\"",
291 self.pr.git_version
292 ),
293 ));
294 }
295 self.resolve_repo(owner, name).await?;
296 let _g = prgit::pull_locks().lock(&format!("{owner}/{name}")).await;
297 let mut idx = pulls::load_pull_index(self.spine.as_ref(), owner, name).await?;
298 let Some(pr) = idx.pulls.get(&number).cloned() else {
299 return Ok(ForgeResponse::error(404, "not found"));
300 };
301 if pr.state != IssueState::Open {
302 return Ok(ForgeResponse::error(
303 422,
304 format!("pull request is {}", pulls::state_str(pr.state)),
305 ));
306 }
307 let dir = repo::repo_git_dir(&self.repo_root, owner, name)?;
308 if !valid_ref(&pr.base) || parse_head_spec(owner, &pr.head_spec()).is_none() {
310 return Ok(ForgeResponse::error(422, "pull request refs are invalid"));
311 }
312 let Some(base_sha) = prgit::rev_parse(&dir, &format!("refs/heads/{}", pr.base)).await
313 else {
314 return Ok(ForgeResponse::error(
315 422,
316 format!("base branch {} no longer exists", pr.base),
317 ));
318 };
319 if let Some(exp) = expected_base {
320 if !valid_sha(exp) {
321 return Ok(ForgeResponse::error(422, "expectedBase must be a sha"));
322 }
323 if !base_sha.starts_with(exp) {
324 return Ok(json(
325 409,
326 &serde_json::json!({
327 "error": format!(
328 "the base branch has moved since the diff you saw (now at {}), review and retry",
329 &base_sha[..7.min(base_sha.len())]
330 ),
331 "baseSha": base_sha,
332 }),
333 ));
334 }
335 }
336 let Some(head) = self
337 .resolve_head(owner, name, &pr.head_spec(), HeadAccess::ExistingPr)
338 .await?
339 else {
340 return Ok(ForgeResponse::error(
341 422,
342 "the head branch no longer exists",
343 ));
344 };
345 if head.sha == base_sha || prgit::is_ancestor(&dir, &head.sha, &base_sha).await {
346 return Ok(ForgeResponse::error(
347 422,
348 "nothing to merge: the base already contains the head",
349 ));
350 }
351 let fast_forward = prgit::is_ancestor(&dir, &base_sha, &head.sha).await;
352 let merged_sha = if fast_forward {
353 head.sha.clone()
354 } else {
355 let mt = match merge_tree_of(&dir, &base_sha, &head.sha).await {
356 Ok(mt) => mt,
357 Err(e) => {
358 tracing::warn!("forge: merge-tree {owner}/{name} #{number}: {e}");
359 return Ok(ForgeResponse::error(422, "these branches cannot be merged"));
360 }
361 };
362 let Some(tree) = mt.tree.filter(|_| mt.clean) else {
363 return Ok(json(
364 409,
365 &serde_json::json!({"error": "merge conflict", "conflicts": mt.conflicts}),
366 ));
367 };
368 if !valid_sha(&tree) {
369 return Err(ForgeError::Backend(
370 "merge-tree returned a bad tree id".into(),
371 ));
372 }
373 let author = ident(&agent_label(who));
375 let email = format!("{}@forge.invalid", ident(who).replace(' ', "-"));
376 let msg = format!("Merge pull request #{} from {}", pr.number, pr.head_spec());
377 let c = prgit::run_at(
378 &dir,
379 Some(&dir),
380 &[
381 "commit-tree",
382 &tree,
383 "-p",
384 &base_sha,
385 "-p",
386 &head.sha,
387 "-m",
388 &msg,
389 ],
390 &[
391 ("GIT_AUTHOR_NAME", &author),
392 ("GIT_AUTHOR_EMAIL", &email),
393 ("GIT_COMMITTER_NAME", "forge"),
394 ("GIT_COMMITTER_EMAIL", "forge@forge.invalid"),
395 ],
396 )
397 .await?;
398 let sha = c.text().trim().to_string();
399 if !c.ok() || !valid_sha(&sha) {
400 return Err(ForgeError::Backend(format!(
401 "git commit-tree: {}",
402 c.stderr.trim()
403 )));
404 }
405 sha
406 };
407 let refname = format!("refs/heads/{}", pr.base);
410 let upd = prgit::run(&dir, &["update-ref", &refname, &merged_sha, &base_sha]).await?;
411 if !upd.ok() {
412 return Ok(json(
413 409,
414 &serde_json::json!({"error": "the base branch moved during the merge, retry"}),
415 ));
416 }
417 if let Some(p) = idx.pulls.get_mut(&number) {
418 p.state = IssueState::Merged;
419 p.merged = Some(MergeRecord {
420 sha: merged_sha.clone(),
421 merged_by: agent.author_id(),
422 at: crate::now_secs(),
423 base_sha: Some(base_sha),
424 head_sha: Some(head.sha),
425 fast_forward,
426 });
427 }
428 pulls::save_pull_index(self.spine.as_ref(), owner, name, &idx).await?;
429 self.release_head_ref(owner, name, &idx, &pr).await;
430 tracing::info!(
431 "forge: merged PR #{number} into {owner}/{name}@{} ({}{})",
432 pr.base,
433 &merged_sha[..7.min(merged_sha.len())],
434 if fast_forward { ", ff" } else { "" }
435 );
436 self.pr.tip.record_tip(owner, name).await;
437 #[cfg(feature = "anchoring")]
441 if let Ok(dir) = self.resolve_repo(owner, name).await {
442 let branch = repo::browse::default_branch(&dir).await;
443 if let Err(e) = self.record_tip(owner, name, &branch, &merged_sha).await {
444 tracing::warn!("forge: recording the anchor tip after merge failed: {e}");
445 }
446 }
447 Ok(json(
448 200,
449 &serde_json::json!({
450 "number": number,
451 "state": "merged",
452 "sha": merged_sha,
453 "fastForward": fast_forward,
454 }),
455 ))
456 }
457}
458
459#[cfg(test)]
460mod tests {
461 use super::*;
462
463 #[test]
464 fn version_parsing_and_gate() {
465 assert_eq!(parse_git_version("git version 2.55.0"), Some((2, 55)));
466 assert_eq!(
467 parse_git_version("git version 2.39.5 (Apple Git-154)"),
468 Some((2, 39))
469 );
470 assert_eq!(parse_git_version("git version 10.0.1"), Some((10, 0)));
471 assert_eq!(parse_git_version("garbage"), None);
472 assert!(merge_tree_supported("git version 2.38.0"));
473 assert!(merge_tree_supported("git version 3.0.0"));
474 assert!(!merge_tree_supported("git version 2.37.9"));
475 assert!(!merge_tree_supported("git version 1.99.0"));
476 assert!(!merge_tree_supported("unknown"));
477 }
478
479 #[test]
480 fn merge_tree_output_parsing() {
481 let clean = parse_merge_tree("abc123\0", true);
482 assert!(clean.clean);
483 assert_eq!(clean.tree.as_deref(), Some("abc123"));
484 let bad = parse_merge_tree(
485 "abc\0src/main.js\0src/main.js\0a b.txt\0\0Auto-merging x\0",
486 false,
487 );
488 assert!(!bad.clean);
489 assert_eq!(bad.tree, None);
490 assert_eq!(bad.conflicts, vec!["src/main.js", "a b.txt"]);
491 }
492
493 #[test]
494 fn identity_is_sanitised() {
495 assert_eq!(ident("a<b>\nc"), "abc");
496 assert_eq!(ident("<>"), "agent");
497 }
498}