feat(git): add merge primitives for pull requests
merge_plan reports conflicts, fast-forwardability and ahead/behind counts; merge_execute builds the merged tree with merge-tree, writes the commit object and moves the base ref with a compare-and-set update so a concurrent push loses cleanly instead of being overwritten. Supports fast-forward, merge commit, squash and per-commit rebase, then enqueues a post-receive style sync task.
ZhenYi committed
Added lib/git/src/service/merge.rs lib/git/src/service/merge.rs +473 −0 View lib/git/src/service/merge.rs
@@ -0,0 +1,473 @@
1use gitbaby::commit::types::CommitInfo;
2use gitbaby::repo::RepositoryFacade;
3use gix::ObjectId;
4use gix::actor::Signature;
5use gix::bstr::BString;
6use gix::date::Time;
7use gix::objs::Commit;
8use models::pull_request::pull_requests::MergeMethod;
9use serde::{Deserialize, Serialize};
10use time::OffsetDateTime;
11use uuid::Uuid;
12
13use crate::context::facade::GitContext;
14use crate::service::GitService;
15use crate::service::content::SignatureDto;
16use crate::share::access::space_for_dir;
17use crate::share::errors::{GitError, map_baby};
18use crate::sync::SyncQueue;
19use crate::sync::{PostReceiveRefUpdate, PostReceiveTask, enqueue_post_receive};
20
21const MAX_REBASE_COMMITS: u32 = 500;
22
23#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
24pub struct MergePlanDto {
25 pub base_sha: String,
26 pub head_sha: String,
27 pub merge_base: Option<String>,
28 pub fast_forwardable: bool,
29 /// base 上 head 没有的提交数。
30 pub base_only: i64,
31 /// head 上 base 没有的提交数。
32 pub head_only: i64,
33 pub clean: bool,
34 pub conflicts: Vec<String>,
35}
36
37#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
38pub struct MergeResultDto {
39 /// `fast_forward` | `merge_commit` | `squash` | `rebase`
40 pub method: String,
41 pub base_ref: String,
42 pub old_sha: String,
43 pub new_sha: String,
44 pub head_sha: String,
45 /// rebase 时重写出来的提交(按时间正序)。
46 pub rewritten: Vec<String>,
47}
48
49#[derive(Debug, Clone)]
50pub struct ExecuteMergeCmd {
51 pub base_branch: String,
52 pub head_revision: String,
53 pub method: MergeMethod,
54 pub message: Option<String>,
55 pub caller: Uuid,
56 pub allow_ff_only: bool,
57 pub summary: String,
58}
59
60fn hex(oid: &ObjectId) -> String {
61 oid.to_string()
62}
63
64fn baby_signature(sig: &gitbaby::commit::types::Signature) -> Signature {
65 Signature {
66 name: BString::from(sig.name.clone()),
67 email: BString::from(sig.email.clone()),
68 time: Time {
69 seconds: sig.time.unix_timestamp(),
70 offset: 0,
71 },
72 }
73}
74
75fn dto_signature(sig: &SignatureDto) -> Signature {
76 Signature {
77 name: BString::from(sig.name.clone()),
78 email: BString::from(sig.email.clone()),
79 time: Time {
80 seconds: sig.when.unix_timestamp(),
81 offset: 0,
82 },
83 }
84}
85
86impl GitService {
87 pub async fn merge_plan(
88 &self,
89 ctx: &GitContext,
90 base: &str,
91 head: &str,
92 ) -> Result<MergePlanDto, GitError> {
93 let baby = self.baby(ctx);
94 let base_sha = baby.rev_parse(base).await.map_err(map_baby)?;
95 let head_sha = baby.rev_parse(head).await.map_err(map_baby)?;
96 let base_str = hex(&base_sha);
97 let head_str = hex(&head_sha);
98
99 let fast_forwardable = baby
100 .is_ancestor(&base_str, &head_str)
101 .await
102 .map_err(map_baby)?;
103 let merge_base = baby.merge_base(&base_str, &head_str).await.ok();
104 let diverging = baby
105 .diverging_commits(&base_str, &head_str)
106 .await
107 .map_err(map_baby)?;
108 let conflict_files = baby
109 .list_conflict_files(
110 &base_str,
111 &head_str,
112 &gitbaby::conflict::types::ListConflictOptions {
113 allow_tree_conflicts: false,
114 skip_content: true,
115 },
116 )
117 .await
118 .map_err(map_baby)?;
119 let conflicts: Vec<String> = conflict_files
120 .iter()
121 .filter_map(|f| {
122 f.header
123 .our_path
124 .clone()
125 .map(|p| p.to_string_lossy().into_owned())
126 .or_else(|| {
127 f.header
128 .their_path
129 .clone()
130 .map(|p| p.to_string_lossy().into_owned())
131 })
132 })
133 .collect();
134
135 Ok(MergePlanDto {
136 base_sha: base_str,
137 head_sha: head_str,
138 merge_base: merge_base.map(|o| hex(&o)),
139 fast_forwardable,
140 base_only: i64::from(diverging.ahead),
141 head_only: i64::from(diverging.behind),
142 clean: conflicts.is_empty(),
143 conflicts,
144 })
145 }
146
147 pub async fn merge_execute(
148 &self,
149 space_name: String,
150 repo_name: String,
151 cmd: ExecuteMergeCmd,
152 ) -> Result<MergeResultDto, GitError> {
153 let ctx = self.context(space_name, repo_name).await?;
154 let base_ref = format!("refs/heads/{}", cmd.base_branch.trim());
155 if cmd.base_branch.trim().is_empty() || cmd.base_branch.contains([' ', '\0']) {
156 return Err(GitError::InvalidArgument("invalid base branch".to_string()));
157 }
158 let plan = self.merge_plan(&ctx, &base_ref, &cmd.head_revision).await?;
159 if plan.head_only == 0 {
160 return Err(GitError::Conflict(
161 "no commits to merge; head is already contained in base".to_string(),
162 ));
163 }
164 if !plan.clean {
165 return Err(GitError::Conflict(format!(
166 "merge conflict in {} file(s): {}",
167 plan.conflicts.len(),
168 plan.conflicts.join(", ")
169 )));
170 }
171 let now = OffsetDateTime::now_utc();
172 let caller_sig = self.caller_signature(cmd.caller, now).await?;
173 let baby = self.baby(&ctx);
174 let base_oid = crate::service::diffs::parse_oid(&plan.base_sha)?;
175 let head_oid = crate::service::diffs::parse_oid(&plan.head_sha)?;
176
177 let (method, new_oid, rewritten) = match cmd.method {
178 MergeMethod::Merge if plan.fast_forwardable && cmd.allow_ff_only => {
179 ("fast_forward".to_string(), head_oid, Vec::new())
180 }
181 MergeMethod::Merge => {
182 let tree = self.merged_tree(&ctx, &plan).await?;
183 let sha = self
184 .write_commit(
185 &ctx,
186 tree,
187 vec![base_oid, head_oid],
188 dto_signature(&caller_sig),
189 dto_signature(&caller_sig),
190 &cmd.message.clone().unwrap_or_else(|| {
191 format!(
192 "Merge branch '{}' into '{}' {}",
193 branch_label(&cmd.head_revision),
194 cmd.base_branch,
195 cmd.summary
196 )
197 .trim()
198 .to_string()
199 }),
200 )
201 .await?;
202 ("merge_commit".to_string(), sha, Vec::new())
203 }
204 MergeMethod::Squash => {
205 let tree = self.merged_tree(&ctx, &plan).await?;
206 let sha = self
207 .write_commit(
208 &ctx,
209 tree,
210 vec![base_oid],
211 dto_signature(&caller_sig),
212 dto_signature(&caller_sig),
213 &cmd.message.clone().unwrap_or_else(|| {
214 format!("{} ({})", cmd.summary, "squash").trim().to_string()
215 }),
216 )
217 .await?;
218 ("squash".to_string(), sha, Vec::new())
219 }
220 MergeMethod::Rebase => {
221 let (sha, list) = self
222 .rebase_chain(&ctx, &plan, &caller_sig, &cmd.summary)
223 .await?;
224 ("rebase".to_string(), sha, list)
225 }
226 };
227
228 baby.update_ref(&base_ref, new_oid, Some(base_oid))
229 .await
230 .map_err(map_baby)?;
231
232 self.enqueue_ref_sync(
233 &ctx,
234 Some(cmd.caller),
235 &base_ref,
236 &plan.base_sha,
237 &hex(&new_oid),
238 )
239 .await;
240
241 Ok(MergeResultDto {
242 method,
243 base_ref,
244 old_sha: plan.base_sha,
245 new_sha: hex(&new_oid),
246 head_sha: plan.head_sha,
247 rewritten,
248 })
249 }
250
251 async fn merged_tree(
252 &self,
253 ctx: &GitContext,
254 plan: &MergePlanDto,
255 ) -> Result<ObjectId, GitError> {
256 let baby = self.baby(ctx);
257 let result = baby
258 .merge_tree(
259 &plan.base_sha,
260 &plan.head_sha,
261 plan.merge_base.as_deref(),
262 false,
263 )
264 .await
265 .map_err(map_baby)?;
266 if result.has_conflicts {
267 return Err(GitError::Conflict(format!(
268 "merge conflict in {} file(s)",
269 result.conflict_files.len()
270 )));
271 }
272 Ok(result.tree_oid)
273 }
274
275 async fn rebase_chain(
276 &self,
277 ctx: &GitContext,
278 plan: &MergePlanDto,
279 caller_sig: &SignatureDto,
280 summary: &str,
281 ) -> Result<(ObjectId, Vec<String>), GitError> {
282 let baby = self.baby(ctx);
283 let ids = baby
284 .commit_ids_between_reverse(
285 &plan.base_sha,
286 &plan.head_sha,
287 None,
288 Some(MAX_REBASE_COMMITS),
289 )
290 .await
291 .map_err(map_baby)?;
292 if ids.is_empty() {
293 return Err(GitError::Conflict("no commits to rebase".to_string()));
294 }
295 let total = baby
296 .commit_ids_between_reverse(&plan.base_sha, &plan.head_sha, None, None)
297 .await
298 .map_err(map_baby)?;
299 if total.len() > MAX_REBASE_COMMITS as usize {
300 return Err(GitError::InvalidArgument(format!(
301 "rebase supports at most {MAX_REBASE_COMMITS} commits"
302 )));
303 }
304
305 let committer = dto_signature(caller_sig);
306 let mut parent = crate::service::diffs::parse_oid(&plan.base_sha)?;
307 let mut out = Vec::with_capacity(ids.len());
308 for pick in &ids {
309 let info = self.commit_info(ctx, &hex(pick)).await?;
310 let previous = info
311 .parents
312 .first()
313 .cloned()
314 .map(|o| hex(&o))
315 .unwrap_or_else(|| plan.merge_base.clone().unwrap_or_default());
316 // 无父提交(仓库首个提交)按 cherry-pick 语义整树引入。
317 let tree = if info.parents.is_empty() {
318 info.tree_id
319 } else {
320 baby.merge_tree(&hex(&parent), &hex(pick), Some(&previous), false)
321 .await
322 .map_err(|e| match e {
323 gitbaby::error::BabyError::Custom(_) => GitError::Conflict(format!(
324 "rebase stopped while picking {}: {}",
325 hex(pick),
326 e
327 )),
328 other => map_baby(other),
329 })?
330 .tree_oid
331 };
332 let message = if out.is_empty() && !summary.is_empty() && ids.len() == 1 {
333 format!("{}\n\n{}", info.message.trim_end(), summary)
334 } else {
335 info.message.clone()
336 };
337 let sha = self
338 .write_commit(
339 ctx,
340 tree,
341 vec![parent],
342 baby_signature(&info.author),
343 committer.clone(),
344 &message,
345 )
346 .await?;
347 parent = sha;
348 out.push(hex(&sha));
349 }
350 Ok((parent, out))
351 }
352
353 async fn commit_info(&self, ctx: &GitContext, sha: &str) -> Result<CommitInfo, GitError> {
354 self.baby(ctx).get_commit(sha).await.map_err(map_baby)
355 }
356
357 #[allow(clippy::too_many_arguments)]
358 async fn write_commit(
359 &self,
360 ctx: &GitContext,
361 tree: ObjectId,
362 parents: Vec<ObjectId>,
363 author: Signature,
364 committer: Signature,
365 message: &str,
366 ) -> Result<ObjectId, GitError> {
367 let repo = ctx
368 .gix_repo()
369 .await
370 .map_err(|e| GitError::Internal(format!("open repo failed: {e}")))?;
371 let commit = Commit {
372 tree,
373 parents: parents.into(),
374 author,
375 committer,
376 encoding: None,
377 message: BString::from(message.to_string()),
378 extra_headers: Vec::new(),
379 };
380 let header = repo
381 .write_object(&commit)
382 .map_err(|e| GitError::Internal(format!("write commit failed: {e}")))?;
383 crate::service::diffs::parse_oid(&header.to_string())
384 }
385
386 async fn enqueue_ref_sync(
387 &self,
388 ctx: &GitContext,
389 actor: Option<Uuid>,
390 ref_name: &str,
391 old_sha: &str,
392 new_sha: &str,
393 ) {
394 let space = match space_for_dir(self.db_read(), ctx.model.dir_id).await {
395 Ok(Some(space)) => space,
396 Ok(None) => {
397 tracing::warn!(repo_id = %ctx.model.id, "merge sync skipped: owning space not found");
398 return;
399 }
400 Err(e) => {
401 tracing::warn!(error = %e, "merge sync skipped: space lookup failed");
402 return;
403 }
404 };
405 let queue = SyncQueue::new(self.state.redis.clone());
406 let settings = self.state.config.git.sync.clone();
407 let task = PostReceiveTask::new(
408 ctx.model.id,
409 space.id,
410 actor,
411 vec![PostReceiveRefUpdate::new(ref_name, old_sha, new_sha)],
412 );
413 if let Err(e) = enqueue_post_receive(&queue, &settings, &task).await {
414 tracing::warn!(error = %e, repo_id = %ctx.model.id, "merge ref sync enqueue failed");
415 }
416 }
417}
418
419fn branch_label(revision: &str) -> String {
420 revision.rsplit('/').next().unwrap_or(revision).to_string()
421}
422
423#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
424pub struct MergeCommitDto {
425 pub sha: String,
426 pub subject: String,
427 pub message: String,
428 pub author_name: String,
429 pub author_email: String,
430 pub author_at: OffsetDateTime,
431 pub parents: Vec<String>,
432}
433
434const MAX_LISTED_COMMITS: u32 = 200;
435
436impl GitService {
437 /// `base..head` 的提交列表(正序,最多 200 条)。
438 pub async fn merge_commits(
439 &self,
440 space_name: String,
441 repo_name: String,
442 base: &str,
443 head: &str,
444 ) -> Result<Vec<MergeCommitDto>, GitError> {
445 let ctx = self.context(space_name, repo_name).await?;
446 let baby = self.baby(&ctx);
447 let base_oid = baby.rev_parse(base).await.map_err(map_baby)?;
448 let head_oid = baby.rev_parse(head).await.map_err(map_baby)?;
449 let ids = baby
450 .commit_ids_between_reverse(
451 &hex(&base_oid),
452 &hex(&head_oid),
453 None,
454 Some(MAX_LISTED_COMMITS),
455 )
456 .await
457 .map_err(map_baby)?;
458 let mut out = Vec::with_capacity(ids.len());
459 for id in ids {
460 let info = self.commit_info(&ctx, &hex(&id)).await?;
461 out.push(MergeCommitDto {
462 sha: hex(&id),
463 subject: info.message.lines().next().unwrap_or_default().to_string(),
464 message: info.message.clone(),
465 author_name: info.author.name.clone(),
466 author_email: info.author.email.clone(),
467 author_at: info.author.time,
468 parents: info.parents.iter().map(hex).collect(),
469 });
470 }
471 Ok(out)
472 }
473}
Modified lib/git/src/service/mod.rs lib/git/src/service/mod.rs +2 −0 View lib/git/src/service/mod.rs
@@ -46,10 +46,12 @@
4646pub mod licenses;
4747pub mod logs;
4848pub mod maintenance;
49pub mod merge;
4950pub mod packs;
5051pub mod patches;
5152pub mod path_log;
5253pub mod pipelines;
54pub mod protection;
5355pub mod refs;
5456pub mod remotes;
5557pub mod repos;