Skip to main content

solid_pod_rs_forge/
merge.rs

1//! Real merges of pull requests: `git merge-tree --write-tree`, fast-forward
2//! when possible, otherwise a two-parent commit, a compare-and-swap ref
3//! update, and the git version gate.
4//!
5//! Follows the JSS forge `apiPullMerge`, `mergeTreeOf`, `isAncestor`,
6//! `pullMergeInfo` and the `mergeTreeOk` start-up check (cited by name only).
7//! A merge writes the target repository's branch, so it is owner-only exactly
8//! as in JSS: the authenticated caller's owner id must equal the repository
9//! owner. Everything else about a merge (the conflict verdict, the stale-base
10//! guard, the ref update) is decided by git on validated object ids.
11//!
12//! # Examples
13//!
14//! ```
15//! use solid_pod_rs_forge::merge::{merge_tree_supported, parse_git_version};
16//!
17//! assert_eq!(parse_git_version("git version 2.43.0"), Some((2, 43)));
18//! assert!(merge_tree_supported("git version 2.38.0"));
19//! assert!(!merge_tree_supported("git version 2.37.9"));
20//! ```
21
22use 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/// Integration point for the anchoring tier: called after a merge moved a
36/// branch of `<owner>/<repo>`, exactly where JSS calls `recordTip`.
37///
38/// The default [`NoopTipRecorder`] does nothing. The anchoring implementation
39/// compares the default-branch tip with the last mark and returns without
40/// effect when the default branch did not move, so this hook is called after
41/// every successful merge. Failures are the implementation's to log; they never
42/// fail the merge (JSS `recordTipSafe`).
43#[async_trait]
44pub trait TipRecorder: Send + Sync {
45    /// A merge into `<owner>/<repo>` completed.
46    async fn record_tip(&self, owner: &str, repo: &str);
47}
48
49/// The default [`TipRecorder`]: does nothing.
50#[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/// Parse `git version 2.43.0` (or `2.43.0.windows.1`) into `(major, minor)`.
59#[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/// `true` when `version` is at least git 2.38 (`merge-tree --write-tree`).
69#[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/// The installed git's `--version` line, detected once per process.
75#[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/// Result of `git merge-tree --write-tree`.
90#[derive(Debug, Clone, PartialEq, Eq)]
91pub struct MergeTree {
92    /// `true` when the merge is clean.
93    pub clean: bool,
94    /// The merged tree (clean merges only).
95    pub tree: Option<String>,
96    /// Conflicted paths (deduplicated, in git's order).
97    pub conflicts: Vec<String>,
98}
99
100/// Parse the NUL-separated (`-z`) `merge-tree --write-tree --name-only`
101/// output: the tree id, then conflicted names up to an empty field.
102#[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
124/// JSS `mergeTreeOf`: compute the merged tree without a worktree. Exit 0 is a
125/// clean merge, exit 1 a conflict; anything else is an error (for instance
126/// unrelated histories). Both arguments must be object ids.
127pub(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/// Live mergeability of an open pull request (JSS `pullMergeInfo`).
160#[derive(Debug, Clone, Default)]
161pub struct MergeInfo {
162    /// Current base tip.
163    pub base_sha: Option<String>,
164    /// Current head tip.
165    pub head_sha: Option<String>,
166    /// `None` when an end is gone or merge-tree is unavailable.
167    pub mergeable: Option<bool>,
168    /// Conflicted paths.
169    pub conflicts: Vec<String>,
170    /// The merge would be a fast-forward.
171    pub fast_forward: bool,
172    /// The base already contains the head.
173    pub up_to_date: bool,
174}
175
176/// Keep a git identity field to printable characters without `<`, `>`.
177fn 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    /// Is `merge-tree --write-tree` available on this host?
196    #[must_use]
197    pub fn merge_tree_ok(&self) -> bool {
198        self.pr.merge_tree_ok
199    }
200
201    /// Pin the git version string used for the 2.38 gate (the default is the
202    /// installed git, detected once at start-up). Operators and tests use this
203    /// to force the "git too old" path.
204    #[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    /// Install the [`TipRecorder`] called after every successful merge.
212    #[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    /// Resolve both ends of an open pull request and decide mergeability.
219    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    /// `POST .../pulls/<n>/merge` (JSS `apiPullMerge`): a real merge on the
264    /// bare repository, repository owner only.
265    ///
266    /// Order of checks: 401 (anonymous), 403 (not the owner), 501 (git too
267    /// old), then under the repository's pull lock 404, 422 (not open), 409
268    /// (`expected_base` no longer matches), 422 (nothing to merge), the
269    /// fast-forward or `merge-tree` decision (409 with the conflicted paths),
270    /// and finally `update-ref <ref> <new> <old>`, whose old-value guard turns
271    /// a racing push into a 409.
272    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        // The index is on-disk data: re-validate before anything reaches argv.
309        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            // Honest authorship: author = the merging agent, committer = forge.
374            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        // Compare-and-swap: update-ref's old-value guard rejects the write if
408        // the base moved between our read and now (a racing push).
409        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        // Forge-held anchoring: a merge moves the default branch, so the
438        // trail's tip is re-read (the hook above is for embedders; this is
439        // the built-in recorder and a no-op without the feature).
440        #[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}