| | 1 | |
| | 2 | |
| | 3 | |
| | 4 | |
| | 5 | |
| | 6 | |
| | 7 | |
| | 8 | |
| | 9 | |
| | 10 | |
| | 11 | |
| | 12 | |
| | 13 | |
| | 14 | |
| | 15 | |
| | 16 | |
| | 17 | |
| | 18 | |
| | 19 | |
| | 20 | use std::path::Path; |
| | 21 | use std::process::Command; |
| | 22 | |
| | 23 | use crate::GitError; |
| | 24 | |
| | 25 | |
| | 26 | #[derive(Debug, Clone, Copy, PartialEq, Eq)] |
| | 27 | pub enum MergeStrategy { |
| | 28 | |
| | 29 | Merge, |
| | 30 | |
| | 31 | Squash, |
| | 32 | |
| | 33 | Rebase, |
| | 34 | } |
| | 35 | |
| | 36 | impl MergeStrategy { |
| | 37 | |
| | 38 | #[must_use] |
| | 39 | pub fn as_str(self) -> &'static str { |
| | 40 | match self { |
| | 41 | Self::Merge => "merge", |
| | 42 | Self::Squash => "squash", |
| | 43 | Self::Rebase => "rebase", |
| | 44 | } |
| | 45 | } |
| | 46 | |
| | 47 | |
| | 48 | |
| | 49 | #[must_use] |
| | 50 | pub fn from_token(token: &str) -> Self { |
| | 51 | match token { |
| | 52 | "squash" => Self::Squash, |
| | 53 | "rebase" => Self::Rebase, |
| | 54 | _ => Self::Merge, |
| | 55 | } |
| | 56 | } |
| | 57 | } |
| | 58 | |
| | 59 | |
| | 60 | |
| | 61 | #[derive(Debug, Clone, Copy, PartialEq, Eq)] |
| | 62 | pub enum Mergeability { |
| | 63 | |
| | 64 | Clean, |
| | 65 | |
| | 66 | AlreadyMerged, |
| | 67 | |
| | 68 | Conflicts, |
| | 69 | } |
| | 70 | |
| | 71 | |
| | 72 | #[derive(Debug, Clone)] |
| | 73 | pub struct MergeIdentity<'a> { |
| | 74 | |
| | 75 | pub home: &'a Path, |
| | 76 | |
| | 77 | pub name: &'a str, |
| | 78 | |
| | 79 | pub email: &'a str, |
| | 80 | } |
| | 81 | |
| | 82 | |
| | 83 | #[derive(Debug, Clone)] |
| | 84 | pub struct MergeRequest<'a> { |
| | 85 | |
| | 86 | pub binary: &'a str, |
| | 87 | |
| | 88 | pub repo_path: &'a Path, |
| | 89 | |
| | 90 | |
| | 91 | pub workdir: &'a Path, |
| | 92 | |
| | 93 | pub base_branch: &'a str, |
| | 94 | |
| | 95 | pub head_ref: &'a str, |
| | 96 | |
| | 97 | pub strategy: MergeStrategy, |
| | 98 | |
| | 99 | pub message: &'a str, |
| | 100 | |
| | 101 | pub identity: MergeIdentity<'a>, |
| | 102 | } |
| | 103 | |
| | 104 | |
| | 105 | fn rev_parse(binary: &str, repo_path: &Path, home: &Path, rev: &str) -> Result<String, GitError> { |
| | 106 | |
| | 107 | let out = base_command(binary, repo_path, home) |
| | 108 | .args(["rev-parse", "--verify", "--quiet"]) |
| | 109 | .arg(format!("{rev}^{{commit}}")) |
| | 110 | .output() |
| | 111 | .map_err(|source| GitError::Io { |
| | 112 | path: repo_path.to_path_buf(), |
| | 113 | source, |
| | 114 | })?; |
| | 115 | if !out.status.success() { |
| | 116 | return Err(GitError::RevNotFound(rev.to_string())); |
| | 117 | } |
| | 118 | Ok(String::from_utf8_lossy(&out.stdout).trim().to_string()) |
| | 119 | } |
| | 120 | |
| | 121 | |
| | 122 | fn base_command(binary: &str, repo_path: &Path, home: &Path) -> Command { |
| | 123 | let mut cmd = Command::new(binary); |
| | 124 | cmd.env("GIT_DIR", repo_path); |
| | 125 | cmd.env("GIT_TERMINAL_PROMPT", "0"); |
| | 126 | cmd.env("GIT_CONFIG_NOSYSTEM", "1"); |
| | 127 | cmd.env("HOME", home); |
| | 128 | cmd.env_remove("GIT_ALTERNATE_OBJECT_DIRECTORIES"); |
| | 129 | cmd.env_remove("GIT_OBJECT_DIRECTORY"); |
| | 130 | cmd |
| | 131 | } |
| | 132 | |
| | 133 | |
| | 134 | |
| | 135 | |
| | 136 | |
| | 137 | |
| | 138 | |
| | 139 | |
| | 140 | pub fn analyze( |
| | 141 | binary: &str, |
| | 142 | repo_path: &Path, |
| | 143 | home: &Path, |
| | 144 | base_ref: &str, |
| | 145 | head_ref: &str, |
| | 146 | ) -> Result<Mergeability, GitError> { |
| | 147 | let base = rev_parse(binary, repo_path, home, base_ref)?; |
| | 148 | let head = rev_parse(binary, repo_path, home, head_ref)?; |
| | 149 | |
| | 150 | |
| | 151 | let contained = base_command(binary, repo_path, home) |
| | 152 | .args(["merge-base", "--is-ancestor", &head, &base]) |
| | 153 | .status() |
| | 154 | .map_err(|source| GitError::Io { |
| | 155 | path: repo_path.to_path_buf(), |
| | 156 | source, |
| | 157 | })?; |
| | 158 | if contained.success() { |
| | 159 | return Ok(Mergeability::AlreadyMerged); |
| | 160 | } |
| | 161 | |
| | 162 | |
| | 163 | |
| | 164 | |
| | 165 | let out = base_command(binary, repo_path, home) |
| | 166 | .args(["merge-tree", "--write-tree", &base, &head]) |
| | 167 | .output() |
| | 168 | .map_err(|source| GitError::Io { |
| | 169 | path: repo_path.to_path_buf(), |
| | 170 | source, |
| | 171 | })?; |
| | 172 | match out.status.code() { |
| | 173 | Some(0) => Ok(Mergeability::Clean), |
| | 174 | Some(1) => Ok(Mergeability::Conflicts), |
| | 175 | _ => Err(GitError::Merge( |
| | 176 | String::from_utf8_lossy(&out.stderr).trim().to_string(), |
| | 177 | )), |
| | 178 | } |
| | 179 | } |
| | 180 | |
| | 181 | |
| | 182 | |
| | 183 | |
| | 184 | |
| | 185 | |
| | 186 | |
| | 187 | |
| | 188 | |
| | 189 | |
| | 190 | |
| | 191 | |
| | 192 | pub fn merge(req: &MergeRequest) -> Result<String, GitError> { |
| | 193 | let base = rev_parse( |
| | 194 | req.binary, |
| | 195 | req.repo_path, |
| | 196 | req.identity.home, |
| | 197 | req.base_branch, |
| | 198 | )?; |
| | 199 | let head = rev_parse(req.binary, req.repo_path, req.identity.home, req.head_ref)?; |
| | 200 | |
| | 201 | let new_tip = match req.strategy { |
| | 202 | MergeStrategy::Merge => plumbing_commit(req, &base, &[&base, &head])?, |
| | 203 | MergeStrategy::Squash => plumbing_commit(req, &base, &[&base])?, |
| | 204 | MergeStrategy::Rebase => rebase(req, &base, &head)?, |
| | 205 | }; |
| | 206 | |
| | 207 | |
| | 208 | let status = identified(req) |
| | 209 | .args([ |
| | 210 | "update-ref", |
| | 211 | &format!("refs/heads/{}", req.base_branch), |
| | 212 | &new_tip, |
| | 213 | &base, |
| | 214 | ]) |
| | 215 | .status() |
| | 216 | .map_err(|source| GitError::Io { |
| | 217 | path: req.repo_path.to_path_buf(), |
| | 218 | source, |
| | 219 | })?; |
| | 220 | if !status.success() { |
| | 221 | return Err(GitError::Merge(format!( |
| | 222 | "base branch {} moved during merge", |
| | 223 | req.base_branch |
| | 224 | ))); |
| | 225 | } |
| | 226 | Ok(new_tip) |
| | 227 | } |
| | 228 | |
| | 229 | |
| | 230 | |
| | 231 | fn plumbing_commit(req: &MergeRequest, base: &str, parents: &[&str]) -> Result<String, GitError> { |
| | 232 | let head = rev_parse(req.binary, req.repo_path, req.identity.home, req.head_ref)?; |
| | 233 | let tree_out = base_command(req.binary, req.repo_path, req.identity.home) |
| | 234 | .args(["merge-tree", "--write-tree", base, &head]) |
| | 235 | .output() |
| | 236 | .map_err(|source| GitError::Io { |
| | 237 | path: req.repo_path.to_path_buf(), |
| | 238 | source, |
| | 239 | })?; |
| | 240 | match tree_out.status.code() { |
| | 241 | Some(0) => {} |
| | 242 | Some(1) => return Err(GitError::MergeConflict), |
| | 243 | _ => { |
| | 244 | return Err(GitError::Merge( |
| | 245 | String::from_utf8_lossy(&tree_out.stderr).trim().to_string(), |
| | 246 | )); |
| | 247 | } |
| | 248 | } |
| | 249 | let tree = String::from_utf8_lossy(&tree_out.stdout).trim().to_string(); |
| | 250 | |
| | 251 | let mut cmd = identified(req); |
| | 252 | cmd.args(["commit-tree", &tree]); |
| | 253 | for parent in parents { |
| | 254 | cmd.args(["-p", parent]); |
| | 255 | } |
| | 256 | cmd.args(["-m", req.message]); |
| | 257 | let out = cmd.output().map_err(|source| GitError::Io { |
| | 258 | path: req.repo_path.to_path_buf(), |
| | 259 | source, |
| | 260 | })?; |
| | 261 | if !out.status.success() { |
| | 262 | return Err(GitError::Merge( |
| | 263 | String::from_utf8_lossy(&out.stderr).trim().to_string(), |
| | 264 | )); |
| | 265 | } |
| | 266 | Ok(String::from_utf8_lossy(&out.stdout).trim().to_string()) |
| | 267 | } |
| | 268 | |
| | 269 | |
| | 270 | |
| | 271 | fn rebase(req: &MergeRequest, base: &str, head: &str) -> Result<String, GitError> { |
| | 272 | |
| | 273 | |
| | 274 | let linear = base_command(req.binary, req.repo_path, req.identity.home) |
| | 275 | .args(["merge-base", "--is-ancestor", base, head]) |
| | 276 | .status() |
| | 277 | .map_err(|source| GitError::Io { |
| | 278 | path: req.repo_path.to_path_buf(), |
| | 279 | source, |
| | 280 | })?; |
| | 281 | if linear.success() { |
| | 282 | return Ok(head.to_string()); |
| | 283 | } |
| | 284 | |
| | 285 | let result = rebase_in_worktree(req, base, head); |
| | 286 | |
| | 287 | let _ = std::fs::remove_dir_all(req.workdir); |
| | 288 | let _ = base_command(req.binary, req.repo_path, req.identity.home) |
| | 289 | .args(["worktree", "prune"]) |
| | 290 | .status(); |
| | 291 | result |
| | 292 | } |
| | 293 | |
| | 294 | |
| | 295 | fn rebase_in_worktree(req: &MergeRequest, base: &str, head: &str) -> Result<String, GitError> { |
| | 296 | let add = base_command(req.binary, req.repo_path, req.identity.home) |
| | 297 | .args(["worktree", "add", "--detach"]) |
| | 298 | .arg(req.workdir) |
| | 299 | .arg(head) |
| | 300 | .output() |
| | 301 | .map_err(|source| GitError::Io { |
| | 302 | path: req.workdir.to_path_buf(), |
| | 303 | source, |
| | 304 | })?; |
| | 305 | if !add.status.success() { |
| | 306 | return Err(GitError::Merge( |
| | 307 | String::from_utf8_lossy(&add.stderr).trim().to_string(), |
| | 308 | )); |
| | 309 | } |
| | 310 | |
| | 311 | let mut cmd = identified(req); |
| | 312 | cmd.current_dir(req.workdir) |
| | 313 | .env_remove("GIT_DIR") |
| | 314 | .args(["rebase", base]); |
| | 315 | let out = cmd.output().map_err(|source| GitError::Io { |
| | 316 | path: req.workdir.to_path_buf(), |
| | 317 | source, |
| | 318 | })?; |
| | 319 | if !out.status.success() { |
| | 320 | |
| | 321 | let _ = identified(req) |
| | 322 | .current_dir(req.workdir) |
| | 323 | .env_remove("GIT_DIR") |
| | 324 | .args(["rebase", "--abort"]) |
| | 325 | .status(); |
| | 326 | return Err(GitError::MergeConflict); |
| | 327 | } |
| | 328 | |
| | 329 | let tip = identified(req) |
| | 330 | .current_dir(req.workdir) |
| | 331 | .env_remove("GIT_DIR") |
| | 332 | .args(["rev-parse", "HEAD"]) |
| | 333 | .output() |
| | 334 | .map_err(|source| GitError::Io { |
| | 335 | path: req.workdir.to_path_buf(), |
| | 336 | source, |
| | 337 | })?; |
| | 338 | Ok(String::from_utf8_lossy(&tip.stdout).trim().to_string()) |
| | 339 | } |
| | 340 | |
| | 341 | |
| | 342 | fn identified(req: &MergeRequest) -> Command { |
| | 343 | let mut cmd = base_command(req.binary, req.repo_path, req.identity.home); |
| | 344 | cmd.env("GIT_AUTHOR_NAME", req.identity.name); |
| | 345 | cmd.env("GIT_AUTHOR_EMAIL", req.identity.email); |
| | 346 | cmd.env("GIT_COMMITTER_NAME", req.identity.name); |
| | 347 | cmd.env("GIT_COMMITTER_EMAIL", req.identity.email); |
| | 348 | cmd |
| | 349 | } |
| | 350 | |
| | 351 | #[cfg(test)] |
| | 352 | mod tests { |
| | 353 | #![allow(clippy::unwrap_used, clippy::expect_used)] |
| | 354 | |
| | 355 | use std::path::{Path, PathBuf}; |
| | 356 | |
| | 357 | use git2::{Repository, Signature, Time}; |
| | 358 | use tempfile::TempDir; |
| | 359 | |
| | 360 | use super::*; |
| | 361 | use crate::{create_bare, repo_path}; |
| | 362 | |
| | 363 | const ID: &str = "01hzxk9m2n8p7q6r5s4t3v2w1x"; |
| | 364 | |
| | 365 | |
| | 366 | |
| | 367 | fn git_present() -> bool { |
| | 368 | Command::new("git").arg("--version").output().is_ok() |
| | 369 | } |
| | 370 | |
| | 371 | |
| | 372 | |
| | 373 | struct Fixture { |
| | 374 | _dir: TempDir, |
| | 375 | home: TempDir, |
| | 376 | path: PathBuf, |
| | 377 | } |
| | 378 | |
| | 379 | fn fixture() -> Fixture { |
| | 380 | let dir = tempfile::tempdir().unwrap(); |
| | 381 | create_bare(dir.path(), ID, "main", Path::new("/usr/bin/fabrica")).unwrap(); |
| | 382 | let path = repo_path(dir.path(), ID).unwrap(); |
| | 383 | let raw = Repository::open_bare(&path).unwrap(); |
| | 384 | |
| | 385 | let base = commit(&raw, "main", &[], &[("README.md", "hello\n")], "init", 1); |
| | 386 | commit( |
| | 387 | &raw, |
| | 388 | "feature", |
| | 389 | &[base], |
| | 390 | &[("README.md", "hello\n"), ("feature.txt", "new\n")], |
| | 391 | "add feature", |
| | 392 | 2, |
| | 393 | ); |
| | 394 | Fixture { |
| | 395 | _dir: dir, |
| | 396 | home: tempfile::tempdir().unwrap(), |
| | 397 | path, |
| | 398 | } |
| | 399 | } |
| | 400 | |
| | 401 | fn commit( |
| | 402 | raw: &Repository, |
| | 403 | branch: &str, |
| | 404 | parents: &[git2::Oid], |
| | 405 | files: &[(&str, &str)], |
| | 406 | message: &str, |
| | 407 | t: i64, |
| | 408 | ) -> git2::Oid { |
| | 409 | let mut root = raw.treebuilder(None).unwrap(); |
| | 410 | for (name, content) in files { |
| | 411 | let blob = raw.blob(content.as_bytes()).unwrap(); |
| | 412 | root.insert(name, blob, 0o100_644).unwrap(); |
| | 413 | } |
| | 414 | let tree = raw.find_tree(root.write().unwrap()).unwrap(); |
| | 415 | let sig = Signature::new("Ada", "ada@example.com", &Time::new(t, 0)).unwrap(); |
| | 416 | let parent_commits: Vec<_> = parents |
| | 417 | .iter() |
| | 418 | .map(|p| raw.find_commit(*p).unwrap()) |
| | 419 | .collect(); |
| | 420 | let parent_refs: Vec<&git2::Commit> = parent_commits.iter().collect(); |
| | 421 | raw.commit( |
| | 422 | Some(&format!("refs/heads/{branch}")), |
| | 423 | &sig, |
| | 424 | &sig, |
| | 425 | message, |
| | 426 | &tree, |
| | 427 | &parent_refs, |
| | 428 | ) |
| | 429 | .unwrap() |
| | 430 | } |
| | 431 | |
| | 432 | fn request<'a>( |
| | 433 | fx: &'a Fixture, |
| | 434 | strategy: MergeStrategy, |
| | 435 | workdir: &'a Path, |
| | 436 | ) -> MergeRequest<'a> { |
| | 437 | MergeRequest { |
| | 438 | binary: "git", |
| | 439 | repo_path: &fx.path, |
| | 440 | workdir, |
| | 441 | base_branch: "main", |
| | 442 | head_ref: "feature", |
| | 443 | strategy, |
| | 444 | message: "Merge feature", |
| | 445 | identity: MergeIdentity { |
| | 446 | home: fx.home.path(), |
| | 447 | name: "Ada", |
| | 448 | email: "ada@example.com", |
| | 449 | }, |
| | 450 | } |
| | 451 | } |
| | 452 | |
| | 453 | |
| | 454 | |
| | 455 | fn tip(fx: &Fixture, rev: &str) -> String { |
| | 456 | rev_parse("git", &fx.path, fx.home.path(), rev).unwrap() |
| | 457 | } |
| | 458 | |
| | 459 | #[test] |
| | 460 | fn analyze_reports_clean_and_already_merged() { |
| | 461 | if !git_present() { |
| | 462 | return; |
| | 463 | } |
| | 464 | let fx = fixture(); |
| | 465 | assert_eq!( |
| | 466 | analyze("git", &fx.path, fx.home.path(), "main", "feature").unwrap(), |
| | 467 | Mergeability::Clean, |
| | 468 | ); |
| | 469 | |
| | 470 | assert_eq!( |
| | 471 | analyze("git", &fx.path, fx.home.path(), "feature", "main").unwrap(), |
| | 472 | Mergeability::AlreadyMerged, |
| | 473 | ); |
| | 474 | } |
| | 475 | |
| | 476 | #[test] |
| | 477 | fn merge_commit_has_two_parents_and_the_feature_file() { |
| | 478 | if !git_present() { |
| | 479 | return; |
| | 480 | } |
| | 481 | let fx = fixture(); |
| | 482 | let base = tip(&fx, "main"); |
| | 483 | let head = tip(&fx, "feature"); |
| | 484 | let wt = fx.path.parent().unwrap().join("wt-merge"); |
| | 485 | let sha = merge(&request(&fx, MergeStrategy::Merge, &wt)).unwrap(); |
| | 486 | |
| | 487 | assert_eq!(tip(&fx, "main"), sha, "base advanced to the merge commit"); |
| | 488 | let raw = Repository::open_bare(&fx.path).unwrap(); |
| | 489 | let merge_commit = raw.find_commit(git2::Oid::from_str(&sha).unwrap()).unwrap(); |
| | 490 | let parents: Vec<String> = merge_commit.parent_ids().map(|o| o.to_string()).collect(); |
| | 491 | assert_eq!(parents, vec![base, head]); |
| | 492 | |
| | 493 | assert!( |
| | 494 | merge_commit |
| | 495 | .tree() |
| | 496 | .unwrap() |
| | 497 | .get_name("feature.txt") |
| | 498 | .is_some() |
| | 499 | ); |
| | 500 | } |
| | 501 | |
| | 502 | #[test] |
| | 503 | fn squash_has_a_single_parent() { |
| | 504 | if !git_present() { |
| | 505 | return; |
| | 506 | } |
| | 507 | let fx = fixture(); |
| | 508 | let base = tip(&fx, "main"); |
| | 509 | let wt = fx.path.parent().unwrap().join("wt-squash"); |
| | 510 | let sha = merge(&request(&fx, MergeStrategy::Squash, &wt)).unwrap(); |
| | 511 | let raw = Repository::open_bare(&fx.path).unwrap(); |
| | 512 | let commit = raw.find_commit(git2::Oid::from_str(&sha).unwrap()).unwrap(); |
| | 513 | let parents: Vec<String> = commit.parent_ids().map(|o| o.to_string()).collect(); |
| | 514 | assert_eq!(parents, vec![base]); |
| | 515 | assert!(commit.tree().unwrap().get_name("feature.txt").is_some()); |
| | 516 | } |
| | 517 | |
| | 518 | #[test] |
| | 519 | fn rebase_of_a_linear_branch_fast_forwards() { |
| | 520 | if !git_present() { |
| | 521 | return; |
| | 522 | } |
| | 523 | let fx = fixture(); |
| | 524 | let head = tip(&fx, "feature"); |
| | 525 | let wt = fx.path.parent().unwrap().join("wt-rebase"); |
| | 526 | let sha = merge(&request(&fx, MergeStrategy::Rebase, &wt)).unwrap(); |
| | 527 | |
| | 528 | assert_eq!(sha, head); |
| | 529 | assert_eq!(tip(&fx, "main"), head); |
| | 530 | } |
| | 531 | } |