| 1 | # frozen_string_literal: true |
| 2 | |
| 3 | require "rails_helper" |
| 4 | require "tmpdir" |
| 5 | require "fileutils" |
| 6 | |
| 7 | # Covers the git plumbing that backs pull request merging: comparing two |
| 8 | # branches and creating a merge commit without a working tree (the app's |
| 9 | # repos are bare). |
| 10 | RSpec.describe GitRepositoryService do |
| 11 | around do |example| |
| 12 | Dir.mktmpdir("git-merge-spec") do |tmp| |
| 13 | @repo_dir = File.join(tmp, "demo.git") |
| 14 | build_repo(@repo_dir) |
| 15 | example.run |
| 16 | end |
| 17 | end |
| 18 | |
| 19 | describe ".commits_between" do |
| 20 | it "lists commits reachable from head but not base" do |
| 21 | commits = described_class.commits_between(@repo_dir, "main", "feature") |
| 22 | expect(commits.map { |c| c[:subject] }).to eq([ "feature commit" ]) |
| 23 | end |
| 24 | |
| 25 | it "returns nothing once the branches are equal" do |
| 26 | expect(described_class.commits_between(@repo_dir, "main", "main")).to eq([]) |
| 27 | end |
| 28 | end |
| 29 | |
| 30 | describe ".diff" do |
| 31 | it "includes the file added on the feature branch" do |
| 32 | diff = described_class.diff(@repo_dir, "main", "feature") |
| 33 | expect(diff).to include("feature.txt") |
| 34 | end |
| 35 | end |
| 36 | |
| 37 | describe ".mergeable?" do |
| 38 | it "is true for a clean merge" do |
| 39 | expect(described_class.mergeable?(@repo_dir, "main", "feature")).to be(true) |
| 40 | end |
| 41 | |
| 42 | it "is false when both branches touch the same lines" do |
| 43 | expect(described_class.mergeable?(@repo_dir, "conflict-a", "conflict-b")).to be(false) |
| 44 | end |
| 45 | end |
| 46 | |
| 47 | describe ".merge!" do |
| 48 | it "creates a merge commit and advances base" do |
| 49 | before_sha = described_class.commit_sha(@repo_dir, "main") |
| 50 | sha = described_class.merge!( |
| 51 | @repo_dir, base: "main", head: "feature", message: "Merge feature", |
| 52 | author_name: "Test Merger", author_email: "merger@example.com" |
| 53 | ) |
| 54 | |
| 55 | expect(sha).to be_present |
| 56 | expect(described_class.commit_sha(@repo_dir, "main")).to eq(sha) |
| 57 | expect(described_class.commit_sha(@repo_dir, "main")).not_to eq(before_sha) |
| 58 | |
| 59 | commit = described_class.commit(@repo_dir, sha) |
| 60 | expect(commit[:author_name]).to eq("Test Merger") |
| 61 | expect(commit[:author_email]).to eq("merger@example.com") |
| 62 | |
| 63 | # Feature's file is now reachable from main. |
| 64 | expect(described_class.file_content(@repo_dir, "main", "feature.txt")).to eq("hi\n") |
| 65 | end |
| 66 | |
| 67 | it "returns nil and leaves base untouched on conflict" do |
| 68 | before_sha = described_class.commit_sha(@repo_dir, "conflict-a") |
| 69 | sha = described_class.merge!( |
| 70 | @repo_dir, base: "conflict-a", head: "conflict-b", message: "Merge", |
| 71 | author_name: "Test", author_email: "test@example.com" |
| 72 | ) |
| 73 | |
| 74 | expect(sha).to be_nil |
| 75 | expect(described_class.commit_sha(@repo_dir, "conflict-a")).to eq(before_sha) |
| 76 | end |
| 77 | |
| 78 | it "returns nil for a missing branch" do |
| 79 | sha = described_class.merge!( |
| 80 | @repo_dir, base: "main", head: "does-not-exist", message: "Merge", |
| 81 | author_name: "Test", author_email: "test@example.com" |
| 82 | ) |
| 83 | expect(sha).to be_nil |
| 84 | end |
| 85 | end |
| 86 | |
| 87 | def build_repo(bare_path) |
| 88 | FileUtils.mkdir_p(bare_path) |
| 89 | system("git", "init", "--bare", bare_path, exception: true) |
| 90 | Dir.mktmpdir do |work| |
| 91 | system("git", "-C", work, "init", "-b", "main", exception: true) |
| 92 | system("git", "-C", work, "config", "user.email", "t@example.com", exception: true) |
| 93 | system("git", "-C", work, "config", "user.name", "Test", exception: true) |
| 94 | File.write(File.join(work, "README.md"), "# Demo\n") |
| 95 | system("git", "-C", work, "add", ".", exception: true) |
| 96 | system("git", "-C", work, "commit", "-m", "seed", exception: true) |
| 97 | system("git", "-C", work, "remote", "add", "origin", bare_path, exception: true) |
| 98 | system("git", "-C", work, "push", "origin", "main", exception: true) |
| 99 | |
| 100 | system("git", "-C", work, "checkout", "-b", "feature", exception: true) |
| 101 | File.write(File.join(work, "feature.txt"), "hi\n") |
| 102 | system("git", "-C", work, "add", ".", exception: true) |
| 103 | system("git", "-C", work, "commit", "-m", "feature commit", exception: true) |
| 104 | system("git", "-C", work, "push", "origin", "feature", exception: true) |
| 105 | |
| 106 | # Two branches that both edit line 1 of the same file — a genuine conflict. |
| 107 | system("git", "-C", work, "checkout", "main", exception: true) |
| 108 | system("git", "-C", work, "checkout", "-b", "conflict-a", exception: true) |
| 109 | File.write(File.join(work, "shared.txt"), "a\n") |
| 110 | system("git", "-C", work, "add", ".", exception: true) |
| 111 | system("git", "-C", work, "commit", "-m", "conflict a", exception: true) |
| 112 | system("git", "-C", work, "push", "origin", "conflict-a", exception: true) |
| 113 | |
| 114 | system("git", "-C", work, "checkout", "main", exception: true) |
| 115 | system("git", "-C", work, "checkout", "-b", "conflict-b", exception: true) |
| 116 | File.write(File.join(work, "shared.txt"), "b\n") |
| 117 | system("git", "-C", work, "add", ".", exception: true) |
| 118 | system("git", "-C", work, "commit", "-m", "conflict b", exception: true) |
| 119 | system("git", "-C", work, "push", "origin", "conflict-b", exception: true) |
| 120 | end |
| 121 | end |
| 122 | end |