✨ (merge-requests): Add per-repository merge requests with in-core diff & merge
Changes
18 files changed, +1590 -4
MODIFY
README.md
+6 -2
@@ -14,10 +14,14 @@
14
14
- push and private read authenticate with **personal access tokens** (HTTP Basic password)
15
15
- Clone/fetch/push over `ssh://git@<host>:2222/<owner>/<repo>.git`
16
16
- public-key authentication only; keys managed per user in the UI
17
-- Web UI: landing page with login CTA for visitors (`/`), repository list for authenticated users (`/`), public repository browse at `/explore`, file/tree browser with self-hosted syntax highlighting (extension-based language detection, falls back to plain text for unknown extensions and binary files), commit log (paginated), branches & tags, one-time handle selection (`/onboarding`), profile settings (`/settings/profile`). Repository pages share Files / Commits / Branches / Issues tab navigation that preserves the selected ref, and the clone panel has copy-to-clipboard buttons for the HTTP and SSH `git clone` commands. Keyboard shortcuts are an optional, progressive enhancement (`?` opens a help overlay, `Escape` closes it, `g h` goes home) — every page works fully without JavaScript
17
+- Web UI: landing page with login CTA for visitors (`/`), repository list for authenticated users (`/`), public repository browse at `/explore`, file/tree browser with self-hosted syntax highlighting (extension-based language detection, falls back to plain text for unknown extensions and binary files), commit log (paginated), branches & tags, one-time handle selection (`/onboarding`), profile settings (`/settings/profile`). Repository pages share Files / Commits / Branches / Issues / Merge requests tab navigation that preserves the selected ref, and the clone panel has copy-to-clipboard buttons for the HTTP and SSH `git clone` commands. Keyboard shortcuts are an optional, progressive enhancement (`?` opens a help overlay, `Escape` closes it, `g h` goes home) — every page works fully without JavaScript
18
18
- Per-repository issues: title, optional description, per-repo sequential number (`#1`, `#2`, …), and author; created and managed by the repo owner, readable by anyone who can read the repo, via a dedicated "New issue" page
19
19
- Issues move through a fixed lifecycle (Planned → In development → Done); the repo navigation shows the open (Planned + In development) issue count, and Done issues collapse into an "Archive" section on the issues page
20
20
- Issues auto-close from pushed commit messages, GitHub-style (`close(s|d)`/`fix(es|ed)`/`resolve(s|d)` + `#<number>`, e.g. `fixes #12`), over both HTTP and SSH pushes
21
+- Per-repository merge requests: source → target branch within one repo, with a title, optional description, a per-repo sequential number displayed bang-prefixed (`!1`, `!2`, …, distinct from issues' `#`), and an author; created and managed by the repo owner, readable by anyone who can read the repo, via a dedicated "New merge request" page where the owner picks source and target from the repo's branches
22
+- Merge requests move through the lifecycle Open → Merged / Closed; the repo navigation and shared tab bar show the open merge-request count, and merged/closed ones collapse into an "Archive" section on the list page (same pattern as issues)
23
+- The merge request detail page renders the live diff of the source branch relative to the merge base with the target (three-dot diff), file by file with per-line add/delete coloring and a changed-files / +additions / −deletions summary — always computed live from git, never duplicated into the database
24
+- The owner can Merge or Close an open merge request from the detail page; merging runs entirely in-core against the bare repository (no working tree), fast-forwarding when possible or else recording a two-parent merge commit authored by the acting user and advancing the target branch ref. An automatic merge that would conflict is rejected; a source branch already contained in the target is treated as already merged
21
25
- OIDC login (authorization code flow) via `GET /login`; on first login the user account is created without a username and the browser is redirected to `/onboarding`, where the user picks a URL-safe handle (`^[a-z0-9][a-z0-9-]{0,38}$`, unique). The chosen handle — not the OIDC `preferred_username` claim (which is an SPN form in kanidm and not URL-safe) — is used in all repo, SSH, ActivityPub, and webfinger URLs. The `name` claim becomes an editable display name; both can be changed later at `/settings/profile`. A request filter blocks all app pages until a handle is chosen. Logout is local-session only via `POST /logout` (the kanidm provider advertises no `end_session_endpoint`, so RP-Initiated Logout is disabled)
22
26
- Single access policy on all paths: owner read/write, public world-readable, private owner-only
23
27
- **Federation (ForgeFed / ActivityPub)** — *opt-in, off by default.* Public repositories are
@@ -44,7 +48,7 @@
44
48
45
49
Inbound activities must carry a valid HTTP Signature from an **allowlisted** peer; outbound fetches
46
50
are HTTPS-only, allowlist-bound, and SSRF-guarded (no private/loopback/link-local targets). Issues
47
-and pull/merge requests are **not** federated (git-shark has no such features yet).
51
+and merge requests exist locally but are **not** federated yet.
48
52
49
53
> Enabling federation publishes **permanent** actor IDs derived from `base-url`. Set a real,
50
54
> stable, non-loopback HTTPS origin before turning it on; git-shark refuses to emit actor documents
ADD
src/main/java/de/workaround/git/GitMergeService.java
+277 -0
@@ -0,0 +1,277 @@
1
+package de.workaround.git;
2
+
3
+import java.io.ByteArrayOutputStream;
4
+import java.io.IOException;
5
+import java.io.UncheckedIOException;
6
+import java.nio.charset.StandardCharsets;
7
+import java.nio.file.Path;
8
+import java.util.ArrayList;
9
+import java.util.List;
10
+import java.util.Optional;
11
+
12
+import org.eclipse.jgit.diff.DiffEntry;
13
+import org.eclipse.jgit.diff.DiffFormatter;
14
+import org.eclipse.jgit.lib.CommitBuilder;
15
+import org.eclipse.jgit.lib.Constants;
16
+import org.eclipse.jgit.lib.ObjectId;
17
+import org.eclipse.jgit.lib.ObjectInserter;
18
+import org.eclipse.jgit.lib.ObjectReader;
19
+import org.eclipse.jgit.lib.PersonIdent;
20
+import org.eclipse.jgit.lib.RefUpdate;
21
+import org.eclipse.jgit.lib.Repository;
22
+import org.eclipse.jgit.merge.MergeStrategy;
23
+import org.eclipse.jgit.merge.ThreeWayMerger;
24
+import org.eclipse.jgit.revwalk.RevCommit;
25
+import org.eclipse.jgit.revwalk.RevWalk;
26
+import org.eclipse.jgit.revwalk.filter.RevFilter;
27
+import org.eclipse.jgit.storage.file.FileRepositoryBuilder;
28
+import org.eclipse.jgit.treewalk.AbstractTreeIterator;
29
+import org.eclipse.jgit.treewalk.CanonicalTreeParser;
30
+import org.eclipse.jgit.treewalk.EmptyTreeIterator;
31
+import org.eclipse.jgit.util.io.DisabledOutputStream;
32
+
33
+import jakarta.enterprise.context.ApplicationScoped;
34
+
35
+/**
36
+ * Diffing and merging of branches inside a bare repository, used by merge requests. Everything runs
37
+ * in-core (no working tree), so it works against the same bare repos the transports serve. Nothing is
38
+ * duplicated into the database — the diff is always computed live from git.
39
+ */
40
+@ApplicationScoped
41
+public class GitMergeService
42
+{
43
+ /** A single line of a unified diff, tagged so the UI can colour it without re-parsing the patch. */
44
+ public record DiffLine(String type, String text)
45
+ {
46
+ }
47
+
48
+ /** One changed file: its path, the {@link DiffEntry.ChangeType} name, the diff lines and add/del counts. */
49
+ public record FileDiff(String path, String changeType, List<DiffLine> lines, int additions, int deletions)
50
+ {
51
+ }
52
+
53
+ /** The full set of changes the source branch would bring into the target (merge-base..source). */
54
+ public record DiffView(List<FileDiff> files, int additions, int deletions)
55
+ {
56
+ }
57
+
58
+ public enum MergeResult
59
+ {
60
+ /** The source branch was merged (or fast-forwarded) into the target and the target ref advanced. */
61
+ MERGED,
62
+ /** The three-way merge produced conflicts; nothing was written. */
63
+ CONFLICT,
64
+ /** The source branch is already contained in the target; there is nothing to merge. */
65
+ UP_TO_DATE,
66
+ /** One of the branches does not exist. */
67
+ MISSING_BRANCH
68
+ }
69
+
70
+ public boolean branchExists(Path barePath, String branch)
71
+ {
72
+ try (Repository repo = open(barePath))
73
+ {
74
+ return repo.resolve(Constants.R_HEADS + branch) != null;
75
+ }
76
+ catch (IOException e)
77
+ {
78
+ throw new UncheckedIOException(e);
79
+ }
80
+ }
81
+
82
+ /**
83
+ * Computes the changes the source branch introduces on top of the target, i.e. the diff from the merge base
84
+ * of the two branches to the tip of the source (three-dot semantics). Returns empty if either branch is missing.
85
+ */
86
+ public Optional<DiffView> diff(Path barePath, String targetBranch, String sourceBranch)
87
+ {
88
+ try (Repository repo = open(barePath); RevWalk walk = new RevWalk(repo))
89
+ {
90
+ ObjectId targetId = repo.resolve(Constants.R_HEADS + targetBranch);
91
+ ObjectId sourceId = repo.resolve(Constants.R_HEADS + sourceBranch);
92
+ if (targetId == null || sourceId == null)
93
+ {
94
+ return Optional.empty();
95
+ }
96
+ RevCommit source = walk.parseCommit(sourceId);
97
+ RevCommit base = mergeBase(repo, targetId, sourceId);
98
+
99
+ List<FileDiff> files = new ArrayList<>();
100
+ int totalAdd = 0;
101
+ int totalDel = 0;
102
+ try (ObjectReader reader = repo.newObjectReader())
103
+ {
104
+ AbstractTreeIterator oldTree = base == null ? new EmptyTreeIterator() : tree(reader, base);
105
+ AbstractTreeIterator newTree = tree(reader, source);
106
+ List<DiffEntry> entries;
107
+ try (DiffFormatter scanner = new DiffFormatter(DisabledOutputStream.INSTANCE))
108
+ {
109
+ scanner.setRepository(repo);
110
+ entries = scanner.scan(oldTree, newTree);
111
+ }
112
+ for (DiffEntry entry : entries)
113
+ {
114
+ FileDiff file = formatEntry(repo, entry);
115
+ files.add(file);
116
+ totalAdd += file.additions();
117
+ totalDel += file.deletions();
118
+ }
119
+ }
120
+ return Optional.of(new DiffView(files, totalAdd, totalDel));
121
+ }
122
+ catch (IOException e)
123
+ {
124
+ throw new UncheckedIOException(e);
125
+ }
126
+ }
127
+
128
+ /**
129
+ * Merges the source branch into the target branch and advances the target ref. Fast-forwards when possible,
130
+ * otherwise records a two-parent merge commit authored by the given identity. Runs entirely in-core, so no
131
+ * working tree is required. Idempotent-ish: an already-merged source reports {@link MergeResult#UP_TO_DATE}.
132
+ */
133
+ public MergeResult merge(Path barePath, String targetBranch, String sourceBranch, String committerName,
134
+ String committerEmail, String message)
135
+ {
136
+ try (Repository repo = open(barePath); RevWalk walk = new RevWalk(repo))
137
+ {
138
+ ObjectId targetId = repo.resolve(Constants.R_HEADS + targetBranch);
139
+ ObjectId sourceId = repo.resolve(Constants.R_HEADS + sourceBranch);
140
+ if (targetId == null || sourceId == null)
141
+ {
142
+ return MergeResult.MISSING_BRANCH;
143
+ }
144
+ RevCommit target = walk.parseCommit(targetId);
145
+ RevCommit source = walk.parseCommit(sourceId);
146
+
147
+ if (walk.isMergedInto(source, target))
148
+ {
149
+ return MergeResult.UP_TO_DATE;
150
+ }
151
+ if (walk.isMergedInto(target, source))
152
+ {
153
+ updateRef(repo, targetBranch, targetId, sourceId, "fast-forward " + sourceBranch);
154
+ return MergeResult.MERGED;
155
+ }
156
+
157
+ ThreeWayMerger merger = MergeStrategy.RECURSIVE.newMerger(repo, true);
158
+ if (!merger.merge(target, source))
159
+ {
160
+ return MergeResult.CONFLICT;
161
+ }
162
+ ObjectId mergedTree = merger.getResultTreeId();
163
+ ObjectId mergeCommit;
164
+ try (ObjectInserter inserter = repo.newObjectInserter())
165
+ {
166
+ CommitBuilder builder = new CommitBuilder();
167
+ builder.setTreeId(mergedTree);
168
+ builder.setParentIds(target, source);
169
+ PersonIdent ident = new PersonIdent(committerName, committerEmail);
170
+ builder.setAuthor(ident);
171
+ builder.setCommitter(ident);
172
+ builder.setMessage(message);
173
+ mergeCommit = inserter.insert(builder);
174
+ inserter.flush();
175
+ }
176
+ updateRef(repo, targetBranch, targetId, mergeCommit, message);
177
+ return MergeResult.MERGED;
178
+ }
179
+ catch (IOException e)
180
+ {
181
+ throw new UncheckedIOException(e);
182
+ }
183
+ }
184
+
185
+ private static FileDiff formatEntry(Repository repo, DiffEntry entry) throws IOException
186
+ {
187
+ ByteArrayOutputStream out = new ByteArrayOutputStream();
188
+ try (DiffFormatter formatter = new DiffFormatter(out))
189
+ {
190
+ formatter.setRepository(repo);
191
+ formatter.format(entry);
192
+ }
193
+ String patch = out.toString(StandardCharsets.UTF_8);
194
+ List<DiffLine> lines = new ArrayList<>();
195
+ int additions = 0;
196
+ int deletions = 0;
197
+ String[] raw = patch.split("\n", -1);
198
+ for (int i = 0; i < raw.length; i++)
199
+ {
200
+ String line = raw[i];
201
+ // a trailing empty element from the final newline is not a real line
202
+ if (i == raw.length - 1 && line.isEmpty())
203
+ {
204
+ break;
205
+ }
206
+ String type;
207
+ if (line.startsWith("@@"))
208
+ {
209
+ type = "hunk";
210
+ }
211
+ else if (line.startsWith("+++") || line.startsWith("---") || line.startsWith("diff ")
212
+ || line.startsWith("index ") || line.startsWith("new file") || line.startsWith("deleted file")
213
+ || line.startsWith("old mode") || line.startsWith("new mode") || line.startsWith("similarity ")
214
+ || line.startsWith("rename ") || line.startsWith("\\"))
215
+ {
216
+ type = "meta";
217
+ }
218
+ else if (line.startsWith("+"))
219
+ {
220
+ type = "add";
221
+ additions++;
222
+ }
223
+ else if (line.startsWith("-"))
224
+ {
225
+ type = "del";
226
+ deletions++;
227
+ }
228
+ else
229
+ {
230
+ type = "context";
231
+ }
232
+ lines.add(new DiffLine(type, line));
233
+ }
234
+ String path = entry.getChangeType() == DiffEntry.ChangeType.DELETE ? entry.getOldPath() : entry.getNewPath();
235
+ return new FileDiff(path, entry.getChangeType().name(), lines, additions, deletions);
236
+ }
237
+
238
+ private static RevCommit mergeBase(Repository repo, ObjectId a, ObjectId b) throws IOException
239
+ {
240
+ try (RevWalk walk = new RevWalk(repo))
241
+ {
242
+ walk.setRevFilter(RevFilter.MERGE_BASE);
243
+ walk.markStart(walk.parseCommit(a));
244
+ walk.markStart(walk.parseCommit(b));
245
+ RevCommit base = walk.next();
246
+ return base == null ? null : walk.parseCommit(base);
247
+ }
248
+ }
249
+
250
+ private static AbstractTreeIterator tree(ObjectReader reader, RevCommit commit) throws IOException
251
+ {
252
+ CanonicalTreeParser parser = new CanonicalTreeParser();
253
+ parser.reset(reader, commit.getTree());
254
+ return parser;
255
+ }
256
+
257
+ private static void updateRef(Repository repo, String branch, ObjectId oldId, ObjectId newId, String logMessage)
258
+ throws IOException
259
+ {
260
+ RefUpdate update = repo.updateRef(Constants.R_HEADS + branch);
261
+ update.setNewObjectId(newId);
262
+ update.setExpectedOldObjectId(oldId);
263
+ update.setRefLogMessage(logMessage, false);
264
+ RefUpdate.Result result = update.update();
265
+ if (result != RefUpdate.Result.FAST_FORWARD && result != RefUpdate.Result.FORCED
266
+ && result != RefUpdate.Result.NEW && result != RefUpdate.Result.NO_CHANGE)
267
+ {
268
+ throw new IllegalStateException("Failed to advance " + branch + ": " + result);
269
+ }
270
+ }
271
+
272
+ private static Repository open(Path barePath) throws IOException
273
+ {
274
+ return new FileRepositoryBuilder().setGitDir(barePath.toFile()).setMustExist(true).build();
275
+ }
276
+
277
+}
ADD
src/main/java/de/workaround/git/InvalidMergeRequestException.java
+10 -0
@@ -0,0 +1,10 @@
1
+package de.workaround.git;
2
+
3
+/** Thrown when a merge request is rejected for invalid input or state, e.g. a blank title or a merge conflict. */
4
+public class InvalidMergeRequestException extends RuntimeException
5
+{
6
+ public InvalidMergeRequestException(String message)
7
+ {
8
+ super(message);
9
+ }
10
+}
ADD
src/main/java/de/workaround/git/MergeRequestService.java
+183 -0
@@ -0,0 +1,183 @@
1
+package de.workaround.git;
2
+
3
+import java.nio.file.Path;
4
+import java.time.Instant;
5
+import java.util.List;
6
+import java.util.Optional;
7
+import java.util.UUID;
8
+
9
+import de.workaround.model.MergeRequest;
10
+import de.workaround.model.Repository;
11
+import de.workaround.model.User;
12
+import jakarta.enterprise.context.ApplicationScoped;
13
+import jakarta.inject.Inject;
14
+import jakarta.transaction.Transactional;
15
+
16
+/**
17
+ * Manages per-repository merge requests. Reading follows the repository's read-visibility rule (enforced by
18
+ * callers); creating, merging and closing require write access, i.e. repository ownership. The proposed changes
19
+ * are never stored — they are diffed live from git through {@link GitMergeService}.
20
+ */
21
+@ApplicationScoped
22
+public class MergeRequestService
23
+{
24
+ @Inject
25
+ MergeRequest.Repo mergeRequests;
26
+
27
+ @Inject
28
+ AccessPolicy accessPolicy;
29
+
30
+ @Inject
31
+ GitRepositoryService repositories;
32
+
33
+ @Inject
34
+ GitMergeService gitMerge;
35
+
36
+ @Transactional
37
+ public MergeRequest create(User actor, Repository repository, String title, String description,
38
+ String sourceBranch, String targetBranch)
39
+ {
40
+ requireWrite(actor, repository);
41
+ String trimmedTitle = title == null ? "" : title.strip();
42
+ if (trimmedTitle.isEmpty())
43
+ {
44
+ throw new InvalidMergeRequestException("Merge request title must not be empty");
45
+ }
46
+ String source = normalizeBranch(sourceBranch);
47
+ String target = normalizeBranch(targetBranch);
48
+ if (source.isEmpty() || target.isEmpty())
49
+ {
50
+ throw new InvalidMergeRequestException("Source and target branch must be provided");
51
+ }
52
+ if (source.equals(target))
53
+ {
54
+ throw new InvalidMergeRequestException("Source and target branch must differ");
55
+ }
56
+ Path bare = repositories.repositoryPath(repository);
57
+ if (!gitMerge.branchExists(bare, source))
58
+ {
59
+ throw new InvalidMergeRequestException("Unknown source branch: " + source);
60
+ }
61
+ if (!gitMerge.branchExists(bare, target))
62
+ {
63
+ throw new InvalidMergeRequestException("Unknown target branch: " + target);
64
+ }
65
+
66
+ MergeRequest mr = new MergeRequest();
67
+ mr.repository = repository;
68
+ mr.author = actor;
69
+ mr.number = mergeRequests.maxNumber(repository) + 1;
70
+ mr.title = trimmedTitle;
71
+ mr.description = description == null || description.isBlank() ? null : description.strip();
72
+ mr.sourceBranch = source;
73
+ mr.targetBranch = target;
74
+ mr.status = MergeRequest.Status.OPEN;
75
+ mr.persist();
76
+ return mr;
77
+ }
78
+
79
+ public List<MergeRequest> list(Repository repository)
80
+ {
81
+ return mergeRequests.findByRepository(repository);
82
+ }
83
+
84
+ /** Number of open merge requests in the repository; merged and closed ones are excluded. */
85
+ public long countOpen(Repository repository)
86
+ {
87
+ return mergeRequests.countOpen(repository);
88
+ }
89
+
90
+ public Optional<MergeRequest> find(Repository repository, UUID id)
91
+ {
92
+ return mergeRequests.findByRepositoryAndId(repository, id);
93
+ }
94
+
95
+ /** The live diff (merge-base..source) of the proposed change, or empty if a branch has since disappeared. */
96
+ public Optional<GitMergeService.DiffView> diff(MergeRequest mr)
97
+ {
98
+ Path bare = repositories.repositoryPath(mr.repository);
99
+ return gitMerge.diff(bare, mr.targetBranch, mr.sourceBranch);
100
+ }
101
+
102
+ /**
103
+ * Merges the request's source branch into its target and moves the request to MERGED. A merge conflict or a
104
+ * source already contained in the target is rejected with {@link InvalidMergeRequestException}; only OPEN
105
+ * requests can be merged.
106
+ */
107
+ @Transactional
108
+ public GitMergeService.MergeResult merge(User actor, MergeRequest mr)
109
+ {
110
+ requireWrite(actor, mr.repository);
111
+ MergeRequest managed = mergeRequests.findById(mr.id);
112
+ if (managed == null)
113
+ {
114
+ throw new InvalidMergeRequestException("Merge request no longer exists");
115
+ }
116
+ if (managed.status != MergeRequest.Status.OPEN)
117
+ {
118
+ throw new InvalidMergeRequestException("Only open merge requests can be merged");
119
+ }
120
+ Path bare = repositories.repositoryPath(managed.repository);
121
+ String message = "Merge branch '" + managed.sourceBranch + "' into " + managed.targetBranch
122
+ + " (!" + managed.number + ")";
123
+ GitMergeService.MergeResult result = gitMerge.merge(bare, managed.targetBranch, managed.sourceBranch,
124
+ committerName(actor), committerEmail(actor), message);
125
+ switch (result)
126
+ {
127
+ case MERGED, UP_TO_DATE ->
128
+ {
129
+ managed.status = MergeRequest.Status.MERGED;
130
+ managed.mergedAt = Instant.now();
131
+ }
132
+ case CONFLICT -> throw new InvalidMergeRequestException(
133
+ "Cannot merge automatically: " + managed.sourceBranch + " conflicts with " + managed.targetBranch);
134
+ case MISSING_BRANCH -> throw new InvalidMergeRequestException(
135
+ "Source or target branch no longer exists");
136
+ }
137
+ return result;
138
+ }
139
+
140
+ /** Moves an open merge request to CLOSED without merging. */
141
+ @Transactional
142
+ public void close(User actor, MergeRequest mr)
143
+ {
144
+ requireWrite(actor, mr.repository);
145
+ MergeRequest managed = mergeRequests.findById(mr.id);
146
+ if (managed != null && managed.status == MergeRequest.Status.OPEN)
147
+ {
148
+ managed.status = MergeRequest.Status.CLOSED;
149
+ }
150
+ }
151
+
152
+ private void requireWrite(User actor, Repository repository)
153
+ {
154
+ if (!accessPolicy.canWrite(actor, repository))
155
+ {
156
+ throw new ForbiddenOperationException("Only the repository owner can manage merge requests");
157
+ }
158
+ }
159
+
160
+ private static String normalizeBranch(String branch)
161
+ {
162
+ return branch == null ? "" : branch.strip();
163
+ }
164
+
165
+ private static String committerName(User actor)
166
+ {
167
+ if (actor.displayName != null && !actor.displayName.isBlank())
168
+ {
169
+ return actor.displayName;
170
+ }
171
+ return actor.username;
172
+ }
173
+
174
+ private static String committerEmail(User actor)
175
+ {
176
+ if (actor.email != null && !actor.email.isBlank())
177
+ {
178
+ return actor.email;
179
+ }
180
+ return actor.username + "@git-shark.local";
181
+ }
182
+
183
+}
ADD
src/main/java/de/workaround/model/MergeRequest.java
+90 -0
@@ -0,0 +1,90 @@
1
+package de.workaround.model;
2
+
3
+import java.time.Instant;
4
+import java.util.List;
5
+import java.util.Optional;
6
+import java.util.UUID;
7
+
8
+import org.hibernate.annotations.processing.Find;
9
+import org.hibernate.annotations.processing.HQL;
10
+
11
+import io.quarkus.hibernate.panache.PanacheEntity;
12
+import io.quarkus.hibernate.panache.PanacheRepository;
13
+import jakarta.persistence.Entity;
14
+import jakarta.persistence.EnumType;
15
+import jakarta.persistence.Enumerated;
16
+import jakarta.persistence.GeneratedValue;
17
+import jakarta.persistence.GenerationType;
18
+import jakarta.persistence.Id;
19
+import jakarta.persistence.ManyToOne;
20
+import jakarta.persistence.Table;
21
+
22
+/**
23
+ * A request to merge one branch ({@link #sourceBranch}) into another ({@link #targetBranch}) within a single
24
+ * repository. The proposed changes are diffed live from git; only the request's metadata and lifecycle
25
+ * ({@link Status}) live in the database. Owned by the repository and removed with it (DB-level ON DELETE CASCADE).
26
+ */
27
+@Entity
28
+@Table(name = "merge_requests")
29
+public class MergeRequest implements PanacheEntity.Managed
30
+{
31
+ @Id
32
+ @GeneratedValue(strategy = GenerationType.UUID)
33
+ public UUID id;
34
+
35
+ @ManyToOne(optional = false)
36
+ public Repository repository;
37
+
38
+ @ManyToOne(optional = false)
39
+ public User author;
40
+
41
+ /** Per-repository, human-facing number (#1, #2, ...) assigned on creation; unique within the repository. */
42
+ public int number;
43
+
44
+ public String title;
45
+
46
+ public String description;
47
+
48
+ public String sourceBranch;
49
+
50
+ public String targetBranch;
51
+
52
+ @Enumerated(EnumType.STRING)
53
+ public Status status = Status.OPEN;
54
+
55
+ public Instant createdAt = Instant.now();
56
+
57
+ /** Set when the request is successfully merged; null while OPEN or when CLOSED without merging. */
58
+ public Instant mergedAt;
59
+
60
+ public enum Status
61
+ {
62
+ OPEN("Open"),
63
+ MERGED("Merged"),
64
+ CLOSED("Closed");
65
+
66
+ /** Human-readable label for the UI; the enum name is the stable value used in forms and the DB. */
67
+ public final String label;
68
+
69
+ Status(String label)
70
+ {
71
+ this.label = label;
72
+ }
73
+ }
74
+
75
+ public interface Repo extends PanacheRepository.Managed<MergeRequest, UUID>
76
+ {
77
+ @HQL("select mr from MergeRequest mr join fetch mr.author where mr.repository = :repository order by mr.createdAt desc")
78
+ List<MergeRequest> findByRepository(Repository repository);
79
+
80
+ @Find
81
+ Optional<MergeRequest> findByRepositoryAndId(Repository repository, UUID id);
82
+
83
+ @HQL("select count(mr) from MergeRequest mr where mr.repository = :repository and mr.status = OPEN")
84
+ long countOpen(Repository repository);
85
+
86
+ @HQL("select coalesce(max(mr.number), 0) from MergeRequest mr where mr.repository = :repository")
87
+ int maxNumber(Repository repository);
88
+ }
89
+
90
+}
ADD
src/main/java/de/workaround/web/InvalidMergeRequestExceptionMapper.java
+25 -0
@@ -0,0 +1,25 @@
1
+package de.workaround.web;
2
+
3
+import de.workaround.git.InvalidMergeRequestException;
4
+import jakarta.ws.rs.core.MediaType;
5
+import jakarta.ws.rs.core.Response;
6
+import jakarta.ws.rs.ext.ExceptionMapper;
7
+import jakarta.ws.rs.ext.Provider;
8
+
9
+/**
10
+ * Maps the domain {@link InvalidMergeRequestException} to HTTP 400, so invalid merge-request input or state
11
+ * (blank title, unknown branch, merge conflict) surfaces as a clean bad request instead of a generic 500.
12
+ * Mirrors {@link InvalidIssueExceptionMapper}.
13
+ */
14
+@Provider
15
+public class InvalidMergeRequestExceptionMapper implements ExceptionMapper<InvalidMergeRequestException>
16
+{
17
+ @Override
18
+ public Response toResponse(InvalidMergeRequestException exception)
19
+ {
20
+ return Response.status(Response.Status.BAD_REQUEST)
21
+ .entity(exception.getMessage())
22
+ .type(MediaType.TEXT_PLAIN)
23
+ .build();
24
+ }
25
+}
ADD
src/main/java/de/workaround/web/MergeRequestResource.java
+168 -0
@@ -0,0 +1,168 @@
1
+package de.workaround.web;
2
+
3
+import java.net.URI;
4
+import java.nio.file.Path;
5
+import java.util.List;
6
+import java.util.UUID;
7
+
8
+import de.workaround.account.CurrentUser;
9
+import de.workaround.git.AccessPolicy;
10
+import de.workaround.git.ForbiddenOperationException;
11
+import de.workaround.git.GitBrowseService;
12
+import de.workaround.git.GitMergeService;
13
+import de.workaround.git.GitRepositoryService;
14
+import de.workaround.git.MergeRequestService;
15
+import de.workaround.model.MergeRequest;
16
+import de.workaround.model.Repository;
17
+import de.workaround.model.User;
18
+import io.quarkus.qute.CheckedTemplate;
19
+import io.quarkus.qute.TemplateInstance;
20
+import jakarta.inject.Inject;
21
+import jakarta.ws.rs.Consumes;
22
+import jakarta.ws.rs.FormParam;
23
+import jakarta.ws.rs.GET;
24
+import jakarta.ws.rs.NotFoundException;
25
+import jakarta.ws.rs.POST;
26
+import jakarta.ws.rs.PathParam;
27
+import jakarta.ws.rs.Produces;
28
+import jakarta.ws.rs.core.MediaType;
29
+import jakarta.ws.rs.core.Response;
30
+
31
+@jakarta.ws.rs.Path("/repos/{owner}/{name}/merge-requests")
32
+@Produces(MediaType.TEXT_HTML)
33
+public class MergeRequestResource
34
+{
35
+ @CheckedTemplate
36
+ static class Templates
37
+ {
38
+ static native TemplateInstance mergeRequests(Repository repo, boolean owner, List<MergeRequest> open,
39
+ List<MergeRequest> closed);
40
+
41
+ static native TemplateInstance newMergeRequest(Repository repo, List<String> branches, String defaultBranch);
42
+
43
+ static native TemplateInstance mergeRequest(Repository repo, boolean owner, MergeRequest mr,
44
+ GitMergeService.DiffView diff);
45
+ }
46
+
47
+ @Inject
48
+ CurrentUser currentUser;
49
+
50
+ @Inject
51
+ GitRepositoryService service;
52
+
53
+ @Inject
54
+ GitBrowseService browse;
55
+
56
+ @Inject
57
+ AccessPolicy accessPolicy;
58
+
59
+ @Inject
60
+ MergeRequestService mergeRequestService;
61
+
62
+ @GET
63
+ public TemplateInstance list(@PathParam("owner") String owner, @PathParam("name") String name)
64
+ {
65
+ Repository repo = requireReadable(owner, name);
66
+ List<MergeRequest> all = mergeRequestService.list(repo);
67
+ List<MergeRequest> open = all.stream().filter(mr -> mr.status == MergeRequest.Status.OPEN).toList();
68
+ List<MergeRequest> closed = all.stream().filter(mr -> mr.status != MergeRequest.Status.OPEN).toList();
69
+ return Templates.mergeRequests(repo, isOwner(repo), open, closed);
70
+ }
71
+
72
+ @GET
73
+ @jakarta.ws.rs.Path("new")
74
+ public TemplateInstance newForm(@PathParam("owner") String owner, @PathParam("name") String name)
75
+ {
76
+ Repository repo = requireReadable(owner, name);
77
+ if (!accessPolicy.canWrite(currentUser.get(), repo))
78
+ {
79
+ throw new ForbiddenOperationException("Only the repository owner can open merge requests");
80
+ }
81
+ Path path = service.repositoryPath(repo);
82
+ List<String> branches = browse.branches(path).stream().map(GitBrowseService.BranchInfo::name).toList();
83
+ String defaultBranch = browse.isEmpty(path) ? null : browse.defaultBranch(path);
84
+ return Templates.newMergeRequest(repo, branches, defaultBranch);
85
+ }
86
+
87
+ @POST
88
+ @Consumes(MediaType.APPLICATION_FORM_URLENCODED)
89
+ public Response create(@PathParam("owner") String owner, @PathParam("name") String name,
90
+ @FormParam("title") String title, @FormParam("description") String description,
91
+ @FormParam("sourceBranch") String sourceBranch, @FormParam("targetBranch") String targetBranch)
92
+ {
93
+ Repository repo = requireReadable(owner, name);
94
+ MergeRequest mr = mergeRequestService.create(currentUser.require(), repo, title, description, sourceBranch,
95
+ targetBranch);
96
+ return Response.seeOther(detailUri(repo, mr.id)).build();
97
+ }
98
+
99
+ @GET
100
+ @jakarta.ws.rs.Path("{id}")
101
+ public TemplateInstance detail(@PathParam("owner") String owner, @PathParam("name") String name,
102
+ @PathParam("id") String id)
103
+ {
104
+ Repository repo = requireReadable(owner, name);
105
+ MergeRequest mr = mergeRequestService.find(repo, parseId(id)).orElseThrow(NotFoundException::new);
106
+ GitMergeService.DiffView diff = mergeRequestService.diff(mr).orElse(null);
107
+ return Templates.mergeRequest(repo, isOwner(repo), mr, diff);
108
+ }
109
+
110
+ @POST
111
+ @jakarta.ws.rs.Path("{id}/merge")
112
+ @Consumes(MediaType.APPLICATION_FORM_URLENCODED)
113
+ public Response merge(@PathParam("owner") String owner, @PathParam("name") String name,
114
+ @PathParam("id") String id)
115
+ {
116
+ Repository repo = requireReadable(owner, name);
117
+ MergeRequest mr = mergeRequestService.find(repo, parseId(id)).orElseThrow(NotFoundException::new);
118
+ mergeRequestService.merge(currentUser.require(), mr);
119
+ return Response.seeOther(detailUri(repo, mr.id)).build();
120
+ }
121
+
122
+ @POST
123
+ @jakarta.ws.rs.Path("{id}/close")
124
+ @Consumes(MediaType.APPLICATION_FORM_URLENCODED)
125
+ public Response close(@PathParam("owner") String owner, @PathParam("name") String name,
126
+ @PathParam("id") String id)
127
+ {
128
+ Repository repo = requireReadable(owner, name);
129
+ MergeRequest mr = mergeRequestService.find(repo, parseId(id)).orElseThrow(NotFoundException::new);
130
+ mergeRequestService.close(currentUser.require(), mr);
131
+ return Response.seeOther(detailUri(repo, mr.id)).build();
132
+ }
133
+
134
+ private URI detailUri(Repository repo, UUID id)
135
+ {
136
+ return URI.create("/repos/" + repo.owner.username + "/" + repo.name + "/merge-requests/" + id);
137
+ }
138
+
139
+ private boolean isOwner(Repository repo)
140
+ {
141
+ User user = currentUser.get();
142
+ return user != null && user.id.equals(repo.owner.id);
143
+ }
144
+
145
+ private static UUID parseId(String id)
146
+ {
147
+ try
148
+ {
149
+ return UUID.fromString(id);
150
+ }
151
+ catch (IllegalArgumentException malformed)
152
+ {
153
+ throw new NotFoundException();
154
+ }
155
+ }
156
+
157
+ private Repository requireReadable(String owner, String name)
158
+ {
159
+ Repository repo = service.find(owner, name).orElseThrow(NotFoundException::new);
160
+ if (!accessPolicy.canRead(currentUser.get(), repo))
161
+ {
162
+ // hide existence of private repositories
163
+ throw new NotFoundException();
164
+ }
165
+ return repo;
166
+ }
167
+
168
+}
MODIFY
src/main/java/de/workaround/web/RepositoryResource.java
+8 -2
@@ -17,6 +17,7 @@
17
17
import de.workaround.git.GitBrowseService;
18
18
import de.workaround.git.GitRepositoryService;
19
19
import de.workaround.git.IssueService;
20
+import de.workaround.git.MergeRequestService;
20
21
import de.workaround.git.RepositoryPinService;
21
22
import de.workaround.model.Repository;
22
23
import de.workaround.model.User;
@@ -48,7 +49,7 @@
48
49
static native TemplateInstance overview(Repository repo, boolean owner, boolean empty, String defaultBranch,
49
50
List<GitBrowseService.TreeEntry> entries, String httpUrl, String sshUrl, boolean loggedIn,
50
51
boolean pinned, GitBrowseService.CommitInfo latestCommit, String latestCommitAge, int commitCount,
51
- int branchCount, int tagCount, long openIssueCount);
52
+ int branchCount, int tagCount, long openIssueCount, long openMrCount);
52
53
53
54
static native TemplateInstance tree(Repository repo, String ref, String path,
54
55
List<GitBrowseService.TreeEntry> entries, List<Crumb> crumbs, String activeTab);
@@ -81,6 +82,9 @@
81
82
@Inject
82
83
IssueService issueService;
83
84
85
+ @Inject
86
+ MergeRequestService mergeRequestService;
87
+
84
88
@ConfigProperty(name = "gitshark.ssh.port")
85
89
int sshPort;
86
90
@@ -111,8 +115,10 @@
111
115
int branchCount = browse.branches(path).size();
112
116
int tagCount = browse.tags(path).size();
113
117
long openIssueCount = issueService.countOpen(repo);
118
+ long openMrCount = mergeRequestService.countOpen(repo);
114
119
return Templates.overview(repo, isOwner, empty, defaultBranch, entries, httpUrl(repo), sshUrl(repo),
115
- loggedIn, pinned, latestCommit, latestCommitAge, commitCount, branchCount, tagCount, openIssueCount);
120
+ loggedIn, pinned, latestCommit, latestCommitAge, commitCount, branchCount, tagCount, openIssueCount,
121
+ openMrCount);
116
122
}
117
123
118
124
private static String relativeAge(Instant when)
MODIFY
src/main/resources/META-INF/resources/shark.css
+129 -0
@@ -838,6 +838,135 @@
838
838
font: 500 12.5px/1 var(--mono);
839
839
}
840
840
841
+/* merge requests */
842
+
843
+.badge.status-OPEN {
844
+ color: var(--accent-deep);
845
+ border-color: transparent;
846
+ background: var(--accent-soft);
847
+}
848
+
849
+.badge.status-MERGED {
850
+ color: #fff;
851
+ border-color: var(--success);
852
+ background: var(--success);
853
+}
854
+
855
+.badge.status-CLOSED {
856
+ color: var(--danger);
857
+ border-color: var(--danger-border);
858
+ background: var(--danger-bg);
859
+}
860
+
861
+.mr-branches {
862
+ font: 500 12.5px/1 var(--mono);
863
+ color: var(--muted);
864
+}
865
+
866
+.mr-branches code {
867
+ color: var(--accent-deep);
868
+}
869
+
870
+.mr-branch-picker {
871
+ display: flex;
872
+ align-items: center;
873
+ gap: var(--s2);
874
+ flex-wrap: wrap;
875
+}
876
+
877
+.mr-branch-picker select {
878
+ font: 500 13px/1 var(--mono);
879
+ padding: 6px 8px;
880
+ border: 1px solid var(--border);
881
+ border-radius: 6px;
882
+ background: var(--surface);
883
+ color: var(--ink);
884
+}
885
+
886
+.mr-branch-picker .into {
887
+ color: var(--muted);
888
+}
889
+
890
+/* diff view */
891
+
892
+.diffstat-summary {
893
+ color: var(--muted);
894
+ font-size: 13px;
895
+}
896
+
897
+.diffstat .add, .diffstat-summary .add {
898
+ color: var(--success-deep);
899
+}
900
+
901
+.diffstat .del, .diffstat-summary .del {
902
+ color: var(--danger);
903
+}
904
+
905
+.diff-file {
906
+ border: 1px solid var(--border);
907
+ border-radius: 8px;
908
+ margin: 0 0 var(--s3);
909
+ overflow: hidden;
910
+}
911
+
912
+.diff-file-head {
913
+ display: flex;
914
+ align-items: center;
915
+ gap: var(--s2);
916
+ padding: var(--s2) var(--s3);
917
+ background: var(--border-soft);
918
+ border-bottom: 1px solid var(--border);
919
+}
920
+
921
+.diff-file-head .diffstat {
922
+ margin-left: auto;
923
+ font: 500 12px/1 var(--mono);
924
+}
925
+
926
+.badge.ct-ADD {
927
+ color: var(--success-deep);
928
+ border-color: transparent;
929
+ background: var(--accent-soft);
930
+}
931
+
932
+.badge.ct-DELETE {
933
+ color: var(--danger);
934
+ border-color: var(--danger-border);
935
+ background: var(--danger-bg);
936
+}
937
+
938
+pre.diff {
939
+ margin: 0;
940
+ padding: 0;
941
+ overflow-x: auto;
942
+ font: 12.5px/1.5 var(--mono);
943
+ background: var(--surface);
944
+}
945
+
946
+pre.diff .dl {
947
+ display: block;
948
+ padding: 0 var(--s3);
949
+ white-space: pre;
950
+ color: var(--ink);
951
+}
952
+
953
+pre.diff .dl.add {
954
+ background: oklch(0.95 0.05 150);
955
+}
956
+
957
+pre.diff .dl.del {
958
+ background: oklch(0.95 0.05 27);
959
+}
960
+
961
+pre.diff .dl.hunk {
962
+ color: var(--accent-deep);
963
+ background: var(--accent-soft);
964
+}
965
+
966
+pre.diff .dl.meta {
967
+ color: var(--faint);
968
+}
969
+
841
970
/* dashboard */
842
971
843
972
.dashboard-section {
ADD
src/main/resources/db/migration/V7__merge_requests.sql
+19 -0
@@ -0,0 +1,19 @@
1
+-- Per-repository merge requests: a request to merge one branch into another, reviewed and merged in the UI.
2
+create table merge_requests
3
+(
4
+ id uuid primary key,
5
+ repository_id uuid not null references repositories (id) on delete cascade,
6
+ author_id uuid not null references users (id) on delete cascade,
7
+ number integer not null,
8
+ title text not null,
9
+ description text,
10
+ source_branch text not null,
11
+ target_branch text not null,
12
+ status varchar(32) not null default 'OPEN'
13
+ check (status in ('OPEN', 'MERGED', 'CLOSED')),
14
+ created_at timestamptz not null default now(),
15
+ merged_at timestamptz,
16
+ constraint merge_requests_repository_number_unique unique (repository_id, number)
17
+);
18
+
19
+create index merge_requests_repository_idx on merge_requests (repository_id);
ADD
src/main/resources/templates/MergeRequestResource/mergeRequest.html
+45 -0
@@ -0,0 +1,45 @@
1
+{#include layout}
2
+{#title}{mr.title} – {repo.name}{/title}
3
+<h1><a href="/repos/{repo.owner.username}/{repo.name}">{repo.owner.username}/{repo.name}</a></h1>
4
+<p><a href="/repos/{repo.owner.username}/{repo.name}/merge-requests">← Merge requests</a></p>
5
+<h2>{mr.title} <span class="issue-no">!{mr.number}</span></h2>
6
+<p>
7
+ <span class="badge status-{mr.status}">{mr.status.label}</span>
8
+ <span class="mr-branches"><code>{mr.sourceBranch}</code> → <code>{mr.targetBranch}</code></span>
9
+ <span class="muted">opened by {mr.author.username}</span>
10
+</p>
11
+{#if mr.description}
12
+<pre class="issue-desc">{mr.description}</pre>
13
+{/if}
14
+{#if owner && mr.status.name() == 'OPEN'}
15
+<div class="issue-actions">
16
+ <form method="post" action="/repos/{repo.owner.username}/{repo.name}/merge-requests/{mr.id}/merge">
17
+ <button type="submit" class="btn btn-primary">Merge</button>
18
+ </form>
19
+ <form method="post" action="/repos/{repo.owner.username}/{repo.name}/merge-requests/{mr.id}/close"
20
+ onsubmit="return confirm('Close this merge request without merging?')">
21
+ <button type="submit" class="btn btn-secondary">Close</button>
22
+ </form>
23
+</div>
24
+{/if}
25
+<h3>Changes</h3>
26
+{#if diff == null || diff.files.isEmpty()}
27
+<p class="muted">No changes to show. The source branch has nothing new over the target.</p>
28
+{#else}
29
+<p class="diffstat-summary">
30
+ <b>{diff.files.size()}</b> file{#if diff.files.size() != 1}s{/if} changed,
31
+ <span class="add">+{diff.additions}</span> <span class="del">-{diff.deletions}</span>
32
+</p>
33
+{#for file in diff.files}
34
+<div class="diff-file">
35
+ <div class="diff-file-head">
36
+ <span class="badge ct-{file.changeType}">{file.changeType}</span>
37
+ <code>{file.path}</code>
38
+ <span class="diffstat"><span class="add">+{file.additions}</span> <span class="del">-{file.deletions}</span></span>
39
+ </div>
40
+ <pre class="diff">{#for line in file.lines}<span class="dl {line.type}">{line.text}</span>
41
+{/for}</pre>
42
+</div>
43
+{/for}
44
+{/if}
45
+{/include}
ADD
src/main/resources/templates/MergeRequestResource/mergeRequests.html
+43 -0
@@ -0,0 +1,43 @@
1
+{#include layout}
2
+{#title}Merge requests – {repo.name}{/title}
3
+<h1><a href="/repos/{repo.owner.username}/{repo.name}">{repo.owner.username}/{repo.name}</a></h1>
4
+<div class="issues-head">
5
+ <h2>Merge requests</h2>
6
+ {#if owner}
7
+ <a class="btn btn-primary" href="/repos/{repo.owner.username}/{repo.name}/merge-requests/new">New merge request</a>
8
+ {/if}
9
+</div>
10
+{#if open.isEmpty()}
11
+<p class="muted">No open merge requests.</p>
12
+{#else}
13
+<div class="panel">
14
+ {#for mr in open}
15
+ <a class="frow" href="/repos/{repo.owner.username}/{repo.name}/merge-requests/{mr.id}">
16
+ <span class="fname">
17
+ <span class="badge status-{mr.status}">{mr.status.label}</span>
18
+ <span class="n">{mr.title}</span>
19
+ <span class="mr-branches"><code>{mr.sourceBranch}</code> → <code>{mr.targetBranch}</code></span>
20
+ <span class="issue-no">!{mr.number}</span>
21
+ </span>
22
+ </a>
23
+ {/for}
24
+</div>
25
+{/if}
26
+{#if closed.size > 0}
27
+<details class="archive">
28
+ <summary>Archive <span class="ct">{closed.size}</span></summary>
29
+ <div class="panel">
30
+ {#for mr in closed}
31
+ <a class="frow" href="/repos/{repo.owner.username}/{repo.name}/merge-requests/{mr.id}">
32
+ <span class="fname">
33
+ <span class="badge status-{mr.status}">{mr.status.label}</span>
34
+ <span class="n">{mr.title}</span>
35
+ <span class="mr-branches"><code>{mr.sourceBranch}</code> → <code>{mr.targetBranch}</code></span>
36
+ <span class="issue-no">!{mr.number}</span>
37
+ </span>
38
+ </a>
39
+ {/for}
40
+ </div>
41
+</details>
42
+{/if}
43
+{/include}
ADD
src/main/resources/templates/MergeRequestResource/newMergeRequest.html
+31 -0
@@ -0,0 +1,31 @@
1
+{#include layout}
2
+{#title}New merge request – {repo.name}{/title}
3
+<h1><a href="/repos/{repo.owner.username}/{repo.name}">{repo.owner.username}/{repo.name}</a></h1>
4
+<p><a href="/repos/{repo.owner.username}/{repo.name}/merge-requests">← Merge requests</a></p>
5
+<h2>New merge request</h2>
6
+<form class="issue-form" method="post" action="/repos/{repo.owner.username}/{repo.name}/merge-requests">
7
+ <input type="text" name="title" placeholder="Merge request title" required autocomplete="off">
8
+ <div class="mr-branch-picker">
9
+ <label>Merge
10
+ <select name="sourceBranch" required>
11
+ {#for branch in branches}
12
+ <option value="{branch}">{branch}</option>
13
+ {/for}
14
+ </select>
15
+ </label>
16
+ <span class="into">into</span>
17
+ <label>
18
+ <select name="targetBranch" required>
19
+ {#for branch in branches}
20
+ <option value="{branch}"{#if branch == defaultBranch} selected{/if}>{branch}</option>
21
+ {/for}
22
+ </select>
23
+ </label>
24
+ </div>
25
+ <textarea name="description" placeholder="Description (optional)" rows="6"></textarea>
26
+ <div class="form-actions">
27
+ <button type="submit" class="btn btn-primary">Create merge request</button>
28
+ <a class="btn btn-secondary" href="/repos/{repo.owner.username}/{repo.name}/merge-requests">Cancel</a>
29
+ </div>
30
+</form>
31
+{/include}
MODIFY
src/main/resources/templates/RepositoryResource/overview.html
+1 -0
@@ -32,6 +32,7 @@
32
32
<a href="/repos/{repo.owner.username}/{repo.name}/branches"><span class="g">⑂</span> Branches <span class="ct">{branchCount}</span></a>
33
33
<a href="/repos/{repo.owner.username}/{repo.name}/branches"><span class="g">⬡</span> Tags <span class="ct">{tagCount}</span></a>
34
34
<a href="/repos/{repo.owner.username}/{repo.name}/issues"><span class="g">◇</span> Issues <span class="ct">{openIssueCount}</span></a>
35
+ <a href="/repos/{repo.owner.username}/{repo.name}/merge-requests"><span class="g">⇄</span> Merge requests <span class="ct">{openMrCount}</span></a>
35
36
</nav>
36
37
</aside>
37
38
MODIFY
src/main/resources/templates/RepositoryResource/tabs.html
+1 -0
@@ -4,5 +4,6 @@
4
4
<a class="tab{#if activeTab == 'commits'} active{/if}" href="/repos/{repo.owner.username}/{repo.name}/commits/{tabRef}">Commits</a>
5
5
<a class="tab{#if activeTab == 'branches'} active{/if}" href="/repos/{repo.owner.username}/{repo.name}/branches">Branches</a>
6
6
<a class="tab{#if activeTab == 'issues'} active{/if}" href="/repos/{repo.owner.username}/{repo.name}/issues">Issues</a>
7
+ <a class="tab{#if activeTab == 'merge-requests'} active{/if}" href="/repos/{repo.owner.username}/{repo.name}/merge-requests">Merge requests</a>
7
8
</nav>
8
9
{/if}
ADD
src/test/java/de/workaround/git/GitMergeServiceTest.java
+170 -0
@@ -0,0 +1,170 @@
1
+package de.workaround.git;
2
+
3
+import java.nio.charset.StandardCharsets;
4
+import java.nio.file.Files;
5
+import java.nio.file.Path;
6
+import java.util.List;
7
+import java.util.Map;
8
+
9
+import org.eclipse.jgit.api.Git;
10
+import org.eclipse.jgit.transport.RefSpec;
11
+import org.junit.jupiter.api.Test;
12
+
13
+import io.quarkus.test.junit.QuarkusTest;
14
+import jakarta.inject.Inject;
15
+
16
+import static org.junit.jupiter.api.Assertions.assertEquals;
17
+import static org.junit.jupiter.api.Assertions.assertFalse;
18
+import static org.junit.jupiter.api.Assertions.assertTrue;
19
+
20
+@QuarkusTest
21
+class GitMergeServiceTest
22
+{
23
+ @Inject
24
+ GitRepositoryService service;
25
+
26
+ @Inject
27
+ GitMergeService merge;
28
+
29
+ @Inject
30
+ de.workaround.model.User.Repo userRepo;
31
+
32
+ @Test
33
+ void diffListsFilesChangedOnTheSourceBranchRelativeToTheTarget() throws Exception
34
+ {
35
+ Path bare = seededRepo("gm-diff");
36
+ branchWithChange(bare, "feature", "hello.txt", "hello world\n");
37
+
38
+ GitMergeService.DiffView diff = merge.diff(bare, "main", "feature").orElseThrow();
39
+
40
+ assertEquals(1, diff.files().size());
41
+ GitMergeService.FileDiff file = diff.files().get(0);
42
+ assertEquals("hello.txt", file.path());
43
+ assertTrue(file.additions() >= 1, "the new line should be counted as an addition");
44
+ assertTrue(diff.files().get(0).lines().stream().anyMatch(l -> l.text().contains("hello world")));
45
+ }
46
+
47
+ @Test
48
+ void mergeCreatesAMergeCommitAndAdvancesTheTargetBranch() throws Exception
49
+ {
50
+ Path bare = seededRepo("gm-merge");
51
+ branchWithChange(bare, "feature", "feature.txt", "new feature\n");
52
+
53
+ GitMergeService.MergeResult result = merge.merge(bare, "main", "feature", "Alice", "alice@example.com",
54
+ "Merge feature into main");
55
+
56
+ assertEquals(GitMergeService.MergeResult.MERGED, result);
57
+ // the merged file is now reachable from main
58
+ assertTrue(fileExistsOnBranch(bare, "main", "feature.txt"),
59
+ "after merging, the feature file must be present on main");
60
+ }
61
+
62
+ @Test
63
+ void mergeReportsConflictWhenBothBranchesChangeTheSameLines() throws Exception
64
+ {
65
+ Path bare = seededRepo("gm-conflict");
66
+ // diverge: change base.txt on main and differently on feature
67
+ branchWithChange(bare, "feature", "base.txt", "feature side\n");
68
+ commitOnBranch(bare, "main", "base.txt", "main side\n");
69
+
70
+ GitMergeService.MergeResult result = merge.merge(bare, "main", "feature", "Alice", "alice@example.com",
71
+ "Merge");
72
+
73
+ assertEquals(GitMergeService.MergeResult.CONFLICT, result);
74
+ }
75
+
76
+ @Test
77
+ void mergeReportsUpToDateWhenSourceIsAlreadyContainedInTarget() throws Exception
78
+ {
79
+ Path bare = seededRepo("gm-uptodate");
80
+ // feature points at the same commit as main -> nothing to merge
81
+ createBranchAt(bare, "feature", "main");
82
+
83
+ GitMergeService.MergeResult result = merge.merge(bare, "main", "feature", "Alice", "alice@example.com",
84
+ "Merge");
85
+
86
+ assertEquals(GitMergeService.MergeResult.UP_TO_DATE, result);
87
+ }
88
+
89
+ @Test
90
+ void branchExistenceIsReported() throws Exception
91
+ {
92
+ Path bare = seededRepo("gm-exists");
93
+ assertTrue(merge.branchExists(bare, "main"));
94
+ assertFalse(merge.branchExists(bare, "does-not-exist"));
95
+ }
96
+
97
+ private Path seededRepo(String name) throws Exception
98
+ {
99
+ de.workaround.model.User owner = persistUser(name);
100
+ de.workaround.model.Repository repo = service.create(owner, name,
101
+ de.workaround.model.Repository.Visibility.PUBLIC, null);
102
+ Path bare = service.repositoryPath(repo);
103
+ GitTestSeeder.seed(bare, Map.of("base.txt", "base\n".getBytes(StandardCharsets.UTF_8)));
104
+ return bare;
105
+ }
106
+
107
+ /** Branches off main, adds/updates one file, pushes the branch. Leaves main untouched. */
108
+ private static void branchWithChange(Path bare, String branch, String file, String content) throws Exception
109
+ {
110
+ Path work = Files.createTempDirectory("mergework");
111
+ try (Git git = Git.cloneRepository().setURI(bare.toUri().toString()).setDirectory(work.toFile()).call())
112
+ {
113
+ git.checkout().setCreateBranch(true).setName(branch).call();
114
+ Files.writeString(work.resolve(file), content);
115
+ git.add().addFilepattern(".").call();
116
+ git.commit().setMessage("change on " + branch).setSign(false)
117
+ .setAuthor("dev", "dev@example.com").setCommitter("dev", "dev@example.com").call();
118
+ git.push().setRefSpecs(new RefSpec(branch + ":refs/heads/" + branch)).call();
119
+ }
120
+ }
121
+
122
+ private static void commitOnBranch(Path bare, String branch, String file, String content) throws Exception
123
+ {
124
+ Path work = Files.createTempDirectory("mergework");
125
+ try (Git git = Git.cloneRepository().setURI(bare.toUri().toString()).setBranch(branch)
126
+ .setDirectory(work.toFile()).call())
127
+ {
128
+ Files.writeString(work.resolve(file), content);
129
+ git.add().addFilepattern(".").call();
130
+ git.commit().setMessage("change on " + branch).setSign(false)
131
+ .setAuthor("dev", "dev@example.com").setCommitter("dev", "dev@example.com").call();
132
+ git.push().setRefSpecs(new RefSpec(branch + ":refs/heads/" + branch)).call();
133
+ }
134
+ }
135
+
136
+ private static void createBranchAt(Path bare, String branch, String at) throws Exception
137
+ {
138
+ Path work = Files.createTempDirectory("mergework");
139
+ try (Git git = Git.cloneRepository().setURI(bare.toUri().toString()).setBranch(at)
140
+ .setDirectory(work.toFile()).call())
141
+ {
142
+ git.push().setRefSpecs(new RefSpec(at + ":refs/heads/" + branch)).call();
143
+ }
144
+ }
145
+
146
+ private static boolean fileExistsOnBranch(Path bare, String branch, String file) throws Exception
147
+ {
148
+ Path work = Files.createTempDirectory("mergecheck");
149
+ try (Git git = Git.cloneRepository().setURI(bare.toUri().toString()).setBranch(branch)
150
+ .setDirectory(work.toFile()).call())
151
+ {
152
+ return Files.exists(work.resolve(file));
153
+ }
154
+ }
155
+
156
+ @jakarta.transaction.Transactional
157
+ de.workaround.model.User persistUser(String name)
158
+ {
159
+ de.workaround.model.User existing = userRepo.findByOidcSubOptional(name).orElse(null);
160
+ if (existing != null)
161
+ {
162
+ return existing;
163
+ }
164
+ de.workaround.model.User user = new de.workaround.model.User();
165
+ user.oidcSub = name;
166
+ user.username = name;
167
+ user.persist();
168
+ return user;
169
+ }
170
+}
ADD
src/test/java/de/workaround/git/MergeRequestServiceTest.java
+198 -0
@@ -0,0 +1,198 @@
1
+package de.workaround.git;
2
+
3
+import java.nio.charset.StandardCharsets;
4
+import java.nio.file.Files;
5
+import java.nio.file.Path;
6
+import java.util.Map;
7
+import java.util.UUID;
8
+
9
+import org.eclipse.jgit.api.Git;
10
+import org.eclipse.jgit.transport.RefSpec;
11
+import org.junit.jupiter.api.Test;
12
+
13
+import de.workaround.model.MergeRequest;
14
+import de.workaround.model.Repository;
15
+import de.workaround.model.User;
16
+import io.quarkus.test.junit.QuarkusTest;
17
+import jakarta.inject.Inject;
18
+import jakarta.transaction.Transactional;
19
+
20
+import static org.junit.jupiter.api.Assertions.assertEquals;
21
+import static org.junit.jupiter.api.Assertions.assertNotNull;
22
+import static org.junit.jupiter.api.Assertions.assertNull;
23
+import static org.junit.jupiter.api.Assertions.assertThrows;
24
+import static org.junit.jupiter.api.Assertions.assertTrue;
25
+
26
+@QuarkusTest
27
+class MergeRequestServiceTest
28
+{
29
+ @Inject
30
+ GitRepositoryService service;
31
+
32
+ @Inject
33
+ MergeRequestService mergeRequests;
34
+
35
+ @Inject
36
+ MergeRequest.Repo repo;
37
+
38
+ @Inject
39
+ User.Repo userRepo;
40
+
41
+ @Test
42
+ void createStoresAnOpenMergeRequestWithSourceAndTargetBranch() throws Exception
43
+ {
44
+ User owner = persistUser("mr-alice");
45
+ Repository repository = seed(owner, "mra");
46
+
47
+ MergeRequest mr = mergeRequests.create(owner, repository, "Add feature", "does things", "feature", "main");
48
+
49
+ assertEquals("Add feature", mr.title);
50
+ assertEquals("does things", mr.description);
51
+ assertEquals("feature", mr.sourceBranch);
52
+ assertEquals("main", mr.targetBranch);
53
+ assertEquals(MergeRequest.Status.OPEN, mr.status);
54
+ assertEquals(owner.id, mr.author.id);
55
+ assertEquals(1, mr.number);
56
+ }
57
+
58
+ @Test
59
+ void createRejectsBlankTitle() throws Exception
60
+ {
61
+ User owner = persistUser("mr-bob");
62
+ Repository repository = seed(owner, "mrb");
63
+ assertThrows(InvalidMergeRequestException.class,
64
+ () -> mergeRequests.create(owner, repository, " ", null, "feature", "main"));
65
+ }
66
+
67
+ @Test
68
+ void createRejectsIdenticalSourceAndTargetBranch() throws Exception
69
+ {
70
+ User owner = persistUser("mr-cara");
71
+ Repository repository = seed(owner, "mrc");
72
+ assertThrows(InvalidMergeRequestException.class,
73
+ () -> mergeRequests.create(owner, repository, "t", null, "main", "main"));
74
+ }
75
+
76
+ @Test
77
+ void createRejectsUnknownBranch() throws Exception
78
+ {
79
+ User owner = persistUser("mr-dan");
80
+ Repository repository = seed(owner, "mrd");
81
+ assertThrows(InvalidMergeRequestException.class,
82
+ () -> mergeRequests.create(owner, repository, "t", null, "ghost", "main"));
83
+ }
84
+
85
+ @Test
86
+ void numbersAreSequentialPerRepository() throws Exception
87
+ {
88
+ User owner = persistUser("mr-erin");
89
+ Repository a = seed(owner, "mre-a");
90
+ Repository b = seed(owner, "mre-b");
91
+ assertEquals(1, mergeRequests.create(owner, a, "1", null, "feature", "main").number);
92
+ assertEquals(2, mergeRequests.create(owner, a, "2", null, "feature", "main").number);
93
+ assertEquals(1, mergeRequests.create(owner, b, "1", null, "feature", "main").number,
94
+ "numbering restarts per repository");
95
+ }
96
+
97
+ @Test
98
+ void mergingMovesTheMergeRequestToMergedAndAdvancesTheTarget() throws Exception
99
+ {
100
+ User owner = persistUser("mr-finn");
101
+ Repository repository = seed(owner, "mrf");
102
+ MergeRequest mr = mergeRequests.create(owner, repository, "Merge me", null, "feature", "main");
103
+
104
+ GitMergeService.MergeResult result = mergeRequests.merge(owner, mr);
105
+
106
+ assertEquals(GitMergeService.MergeResult.MERGED, result);
107
+ MergeRequest reloaded = repo.findById(mr.id);
108
+ assertEquals(MergeRequest.Status.MERGED, reloaded.status);
109
+ assertNotNull(reloaded.mergedAt);
110
+ }
111
+
112
+ @Test
113
+ void closingMovesTheMergeRequestToClosedWithoutMerging() throws Exception
114
+ {
115
+ User owner = persistUser("mr-gwen");
116
+ Repository repository = seed(owner, "mrg");
117
+ MergeRequest mr = mergeRequests.create(owner, repository, "Close me", null, "feature", "main");
118
+
119
+ mergeRequests.close(owner, mr);
120
+
121
+ assertEquals(MergeRequest.Status.CLOSED, repo.findById(mr.id).status);
122
+ }
123
+
124
+ @Test
125
+ void nonOwnerCannotCreateMergeOrClose() throws Exception
126
+ {
127
+ User owner = persistUser("mr-hugo");
128
+ User stranger = persistUser("mr-ivan");
129
+ Repository repository = seed(owner, "mrh");
130
+ MergeRequest mr = mergeRequests.create(owner, repository, "Owned", null, "feature", "main");
131
+
132
+ assertThrows(ForbiddenOperationException.class,
133
+ () -> mergeRequests.create(stranger, repository, "sneaky", null, "feature", "main"));
134
+ assertThrows(ForbiddenOperationException.class, () -> mergeRequests.merge(stranger, mr));
135
+ assertThrows(ForbiddenOperationException.class, () -> mergeRequests.close(stranger, mr));
136
+ }
137
+
138
+ @Test
139
+ void countOpenExcludesMergedAndClosed() throws Exception
140
+ {
141
+ User owner = persistUser("mr-jane");
142
+ Repository repository = seed(owner, "mrj");
143
+ mergeRequests.create(owner, repository, "still open", null, "feature", "main");
144
+ MergeRequest merged = mergeRequests.create(owner, repository, "merged", null, "feature", "main");
145
+ MergeRequest closed = mergeRequests.create(owner, repository, "closed", null, "feature", "main");
146
+ mergeRequests.merge(owner, merged);
147
+ mergeRequests.close(owner, closed);
148
+
149
+ assertEquals(1L, mergeRequests.countOpen(repository));
150
+ }
151
+
152
+ @Test
153
+ void deletingARepositoryRemovesItsMergeRequests() throws Exception
154
+ {
155
+ User owner = persistUser("mr-kim");
156
+ Repository repository = seed(owner, "mrk");
157
+ MergeRequest mr = mergeRequests.create(owner, repository, "Doomed", null, "feature", "main");
158
+ UUID mrId = mr.id;
159
+
160
+ service.delete(owner, repository);
161
+
162
+ assertNull(repo.findById(mrId), "merge request must be cascade-deleted with its repository");
163
+ }
164
+
165
+ /** Creates a repo with a base commit on main plus a divergent 'feature' branch (adds feature.txt). */
166
+ private Repository seed(User owner, String name) throws Exception
167
+ {
168
+ Repository repository = service.create(owner, name, Repository.Visibility.PUBLIC, null);
169
+ Path bare = service.repositoryPath(repository);
170
+ GitTestSeeder.seed(bare, Map.of("base.txt", "base\n".getBytes(StandardCharsets.UTF_8)));
171
+ Path work = Files.createTempDirectory("mrseed");
172
+ try (Git git = Git.cloneRepository().setURI(bare.toUri().toString()).setDirectory(work.toFile()).call())
173
+ {
174
+ git.checkout().setCreateBranch(true).setName("feature").call();
175
+ Files.writeString(work.resolve("feature.txt"), "feature\n");
176
+ git.add().addFilepattern(".").call();
177
+ git.commit().setMessage("feature work").setSign(false)
178
+ .setAuthor("dev", "dev@example.com").setCommitter("dev", "dev@example.com").call();
179
+ git.push().setRefSpecs(new RefSpec("feature:refs/heads/feature")).call();
180
+ }
181
+ return repository;
182
+ }
183
+
184
+ @Transactional
185
+ User persistUser(String name)
186
+ {
187
+ User existing = userRepo.findByOidcSubOptional(name).orElse(null);
188
+ if (existing != null)
189
+ {
190
+ return existing;
191
+ }
192
+ User user = new User();
193
+ user.oidcSub = name;
194
+ user.username = name;
195
+ user.persist();
196
+ return user;
197
+ }
198
+}
ADD
src/test/java/de/workaround/web/MergeRequestUiTest.java
+186 -0
@@ -0,0 +1,186 @@
1
+package de.workaround.web;
2
+
3
+import java.nio.charset.StandardCharsets;
4
+import java.nio.file.Files;
5
+import java.nio.file.Path;
6
+import java.util.Map;
7
+import java.util.UUID;
8
+
9
+import org.eclipse.jgit.api.Git;
10
+import org.eclipse.jgit.transport.RefSpec;
11
+import org.junit.jupiter.api.Test;
12
+
13
+import de.workaround.git.GitRepositoryService;
14
+import de.workaround.git.GitTestSeeder;
15
+import de.workaround.model.Repository;
16
+import de.workaround.model.User;
17
+import io.quarkus.test.junit.QuarkusTest;
18
+import io.quarkus.test.security.TestSecurity;
19
+import jakarta.inject.Inject;
20
+import jakarta.transaction.Transactional;
21
+
22
+import static io.restassured.RestAssured.given;
23
+import static org.hamcrest.CoreMatchers.containsString;
24
+import static org.hamcrest.CoreMatchers.not;
25
+
26
+@QuarkusTest
27
+class MergeRequestUiTest
28
+{
29
+ @Inject
30
+ GitRepositoryService service;
31
+
32
+ @Inject
33
+ User.Repo userRepo;
34
+
35
+ @Test
36
+ @TestSecurity(user = "mru-owner")
37
+ void ownerCanOpenViewDiffAndMergeAMergeRequest()
38
+ {
39
+ User owner = persistUser("mru-owner");
40
+ Repository repo = seed(owner, "board");
41
+ String base = "/repos/" + owner.username + "/board/merge-requests";
42
+
43
+ // the new-MR form lets the owner pick source and target branches
44
+ given().when().get(base + "/new")
45
+ .then().statusCode(200)
46
+ .body(containsString("<form"))
47
+ .body(containsString("name=\"sourceBranch\""))
48
+ .body(containsString("name=\"targetBranch\""))
49
+ .body(containsString("feature"));
50
+
51
+ // create it
52
+ String location = given().redirects().follow(false)
53
+ .contentType("application/x-www-form-urlencoded")
54
+ .formParam("title", "Ship the feature").formParam("description", "adds feature.txt")
55
+ .formParam("sourceBranch", "feature").formParam("targetBranch", "main")
56
+ .when().post(base)
57
+ .then().statusCode(303)
58
+ .extract().header("Location");
59
+
60
+ // list shows it as open
61
+ given().when().get(base)
62
+ .then().statusCode(200)
63
+ .body(containsString("Ship the feature"))
64
+ .body(containsString("Open"));
65
+
66
+ // detail shows the diff of the source branch and a merge control
67
+ given().when().get(location)
68
+ .then().statusCode(200)
69
+ .body(containsString("feature.txt"))
70
+ .body(containsString("feature"))
71
+ .body(containsString("Merge"));
72
+
73
+ // merge it
74
+ given().redirects().follow(false)
75
+ .contentType("application/x-www-form-urlencoded")
76
+ .when().post(location + "/merge")
77
+ .then().statusCode(303);
78
+
79
+ given().when().get(location).then().statusCode(200).body(containsString("Merged"));
80
+ }
81
+
82
+ @Test
83
+ @TestSecurity(user = "mru-closer")
84
+ void ownerCanCloseAMergeRequestWithoutMerging()
85
+ {
86
+ User owner = persistUser("mru-closer");
87
+ Repository repo = seed(owner, "closeboard");
88
+ String base = "/repos/" + owner.username + "/closeboard/merge-requests";
89
+
90
+ String location = given().redirects().follow(false)
91
+ .contentType("application/x-www-form-urlencoded")
92
+ .formParam("title", "Never mind").formParam("sourceBranch", "feature").formParam("targetBranch", "main")
93
+ .when().post(base)
94
+ .then().statusCode(303)
95
+ .extract().header("Location");
96
+
97
+ given().redirects().follow(false)
98
+ .contentType("application/x-www-form-urlencoded")
99
+ .when().post(location + "/close")
100
+ .then().statusCode(303);
101
+
102
+ given().when().get(location).then().statusCode(200).body(containsString("Closed"));
103
+ }
104
+
105
+ @Test
106
+ void anonymousCannotCreateMergeRequests()
107
+ {
108
+ User owner = persistUser("mru-anon-" + UUID.randomUUID().toString().substring(0, 8));
109
+ seed(owner, "board");
110
+
111
+ given().contentType("application/x-www-form-urlencoded")
112
+ .formParam("title", "Sneaky").formParam("sourceBranch", "feature").formParam("targetBranch", "main")
113
+ .when().post("/repos/" + owner.username + "/board/merge-requests")
114
+ .then().statusCode(403);
115
+ }
116
+
117
+ @Test
118
+ @TestSecurity(user = "mru-stranger")
119
+ void privateRepositoryMergeRequestsAreHiddenFromStrangers()
120
+ {
121
+ persistUser("mru-stranger");
122
+ User owner = persistUser("mru-owner2-" + UUID.randomUUID().toString().substring(0, 8));
123
+ Repository repo = service.create(owner, "secret", Repository.Visibility.PRIVATE, null);
124
+
125
+ given().when().get("/repos/" + owner.username + "/secret/merge-requests")
126
+ .then().statusCode(404);
127
+ }
128
+
129
+ @Test
130
+ @TestSecurity(user = "mru-count")
131
+ void repoOverviewShowsOpenMergeRequestCount()
132
+ {
133
+ User owner = persistUser("mru-count");
134
+ Repository repo = seed(owner, "counts");
135
+ String base = "/repos/" + owner.username + "/counts/merge-requests";
136
+ given().redirects().follow(false).contentType("application/x-www-form-urlencoded")
137
+ .formParam("title", "one").formParam("sourceBranch", "feature").formParam("targetBranch", "main")
138
+ .when().post(base).then().statusCode(303);
139
+
140
+ given().when().get("/repos/" + owner.username + "/counts")
141
+ .then().statusCode(200)
142
+ .body(containsString("Merge requests"))
143
+ .body(containsString("<span class=\"ct\">1</span>"));
144
+ }
145
+
146
+ /** Repo with a base commit on main and a divergent 'feature' branch adding feature.txt. */
147
+ private Repository seed(User owner, String name)
148
+ {
149
+ try
150
+ {
151
+ Repository repo = service.create(owner, name, Repository.Visibility.PUBLIC, null);
152
+ Path bare = service.repositoryPath(repo);
153
+ GitTestSeeder.seed(bare, Map.of("base.txt", "base\n".getBytes(StandardCharsets.UTF_8)));
154
+ Path work = Files.createTempDirectory("mruseed");
155
+ try (Git git = Git.cloneRepository().setURI(bare.toUri().toString()).setDirectory(work.toFile()).call())
156
+ {
157
+ git.checkout().setCreateBranch(true).setName("feature").call();
158
+ Files.writeString(work.resolve("feature.txt"), "feature\n");
159
+ git.add().addFilepattern(".").call();
160
+ git.commit().setMessage("feature work").setSign(false)
161
+ .setAuthor("dev", "dev@example.com").setCommitter("dev", "dev@example.com").call();
162
+ git.push().setRefSpecs(new RefSpec("feature:refs/heads/feature")).call();
163
+ }
164
+ return repo;
165
+ }
166
+ catch (Exception e)
167
+ {
168
+ throw new RuntimeException(e);
169
+ }
170
+ }
171
+
172
+ @Transactional
173
+ User persistUser(String name)
174
+ {
175
+ User existing = userRepo.findByOidcSubOptional(name).orElse(null);
176
+ if (existing != null)
177
+ {
178
+ return existing;
179
+ }
180
+ User user = new User();
181
+ user.oidcSub = name;
182
+ user.username = name;
183
+ user.persist();
184
+ return user;
185
+ }
186
+}