Add commits page with graph, diff, file browser, and checkout
New features: - Commits list with ASCII graph, hash, message, author, date - Single commit diff view with per-file diffs - File tree browser at any commit - File content viewer at any commit - Checkout entire commit (detached HEAD) - Checkout selected files from a commit New GitService methods: listCommits, getCommitDiff, listFilesAtCommit, getFileContentAtCommit, checkoutCommit, checkoutFilesFromCommit. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
@@ -58,6 +58,65 @@ public class RepoController
|
||||
return "manage";
|
||||
}
|
||||
|
||||
@GetMapping("/repo/{name}/commits")
|
||||
public String commits(@PathVariable String name, Model model) throws IOException
|
||||
{
|
||||
model.addAttribute("name", name);
|
||||
model.addAttribute("commits", gitService.listCommits(name));
|
||||
return "commits";
|
||||
}
|
||||
|
||||
@GetMapping("/repo/{name}/commit/{hash}")
|
||||
public String commitDetail(@PathVariable String name, @PathVariable String hash, Model model) throws IOException
|
||||
{
|
||||
model.addAttribute("name", name);
|
||||
model.addAttribute("hash", hash);
|
||||
model.addAttribute("shortHash", hash.substring(0, Math.min(7, hash.length())));
|
||||
model.addAttribute("diffs", gitService.getCommitDiff(name, hash));
|
||||
return "commit";
|
||||
}
|
||||
|
||||
@GetMapping("/repo/{name}/tree/{hash}")
|
||||
public String tree(@PathVariable String name, @PathVariable String hash,
|
||||
@RequestParam(required = false, defaultValue = "") String path, Model model) throws IOException
|
||||
{
|
||||
model.addAttribute("name", name);
|
||||
model.addAttribute("hash", hash);
|
||||
model.addAttribute("shortHash", hash.substring(0, Math.min(7, hash.length())));
|
||||
model.addAttribute("path", path);
|
||||
model.addAttribute("files", gitService.listFilesAtCommit(name, hash, path));
|
||||
return "tree";
|
||||
}
|
||||
|
||||
@GetMapping("/repo/{name}/blob/{hash}/**")
|
||||
public String blob(@PathVariable String name, @PathVariable String hash,
|
||||
jakarta.servlet.http.HttpServletRequest request, Model model) throws IOException
|
||||
{
|
||||
String fullPath = request.getRequestURI();
|
||||
String prefix = "/repo/" + name + "/blob/" + hash + "/";
|
||||
String filePath = fullPath.substring(prefix.length());
|
||||
model.addAttribute("name", name);
|
||||
model.addAttribute("hash", hash);
|
||||
model.addAttribute("filePath", filePath);
|
||||
model.addAttribute("content", gitService.getFileContentAtCommit(name, hash, filePath));
|
||||
return "blob";
|
||||
}
|
||||
|
||||
@PostMapping("/repo/{name}/checkout-commit")
|
||||
public String checkoutCommit(@PathVariable String name, @RequestParam String hash) throws IOException, GitAPIException
|
||||
{
|
||||
gitService.checkoutCommit(name, hash);
|
||||
return "redirect:/repo/" + name + "/commits";
|
||||
}
|
||||
|
||||
@PostMapping("/repo/{name}/checkout-files")
|
||||
public String checkoutFiles(@PathVariable String name, @RequestParam String hash,
|
||||
@RequestParam List<String> files) throws IOException, GitAPIException
|
||||
{
|
||||
gitService.checkoutFilesFromCommit(name, hash, files);
|
||||
return "redirect:/repo/" + name + "/changes";
|
||||
}
|
||||
|
||||
@PostMapping("/repo/{name}/checkout")
|
||||
public String checkout(@PathVariable String name, @RequestParam String branch) throws IOException, GitAPIException
|
||||
{
|
||||
|
||||
15
src/main/java/be/seeseepuff/webgit/model/CommitInfo.java
Normal file
15
src/main/java/be/seeseepuff/webgit/model/CommitInfo.java
Normal file
@@ -0,0 +1,15 @@
|
||||
package be.seeseepuff.webgit.model;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public record CommitInfo(
|
||||
String hash,
|
||||
String shortHash,
|
||||
String message,
|
||||
String author,
|
||||
String date,
|
||||
List<String> parentHashes,
|
||||
String graphLine
|
||||
)
|
||||
{
|
||||
}
|
||||
10
src/main/java/be/seeseepuff/webgit/model/DiffInfo.java
Normal file
10
src/main/java/be/seeseepuff/webgit/model/DiffInfo.java
Normal file
@@ -0,0 +1,10 @@
|
||||
package be.seeseepuff.webgit.model;
|
||||
|
||||
public record DiffInfo(
|
||||
String changeType,
|
||||
String oldPath,
|
||||
String newPath,
|
||||
String diff
|
||||
)
|
||||
{
|
||||
}
|
||||
8
src/main/java/be/seeseepuff/webgit/model/FileInfo.java
Normal file
8
src/main/java/be/seeseepuff/webgit/model/FileInfo.java
Normal file
@@ -0,0 +1,8 @@
|
||||
package be.seeseepuff.webgit.model;
|
||||
|
||||
public record FileInfo(
|
||||
String path,
|
||||
String type
|
||||
)
|
||||
{
|
||||
}
|
||||
@@ -1,18 +1,40 @@
|
||||
package be.seeseepuff.webgit.service;
|
||||
|
||||
import be.seeseepuff.webgit.config.WebgitProperties;
|
||||
import be.seeseepuff.webgit.model.CommitInfo;
|
||||
import be.seeseepuff.webgit.model.DiffInfo;
|
||||
import be.seeseepuff.webgit.model.FileInfo;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.eclipse.jgit.api.Git;
|
||||
import org.eclipse.jgit.api.errors.GitAPIException;
|
||||
import org.eclipse.jgit.diff.DiffEntry;
|
||||
import org.eclipse.jgit.diff.DiffFormatter;
|
||||
import org.eclipse.jgit.lib.ObjectId;
|
||||
import org.eclipse.jgit.lib.ObjectLoader;
|
||||
import org.eclipse.jgit.lib.Ref;
|
||||
import org.eclipse.jgit.lib.Repository;
|
||||
import org.eclipse.jgit.lib.StoredConfig;
|
||||
import org.eclipse.jgit.revplot.PlotCommit;
|
||||
import org.eclipse.jgit.revplot.PlotCommitList;
|
||||
import org.eclipse.jgit.revplot.PlotLane;
|
||||
import org.eclipse.jgit.revplot.PlotWalk;
|
||||
import org.eclipse.jgit.revwalk.RevCommit;
|
||||
import org.eclipse.jgit.revwalk.RevTree;
|
||||
import org.eclipse.jgit.storage.file.FileRepositoryBuilder;
|
||||
import org.eclipse.jgit.treewalk.TreeWalk;
|
||||
import org.eclipse.jgit.treewalk.filter.PathFilter;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.time.Instant;
|
||||
import java.time.ZoneId;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
@@ -244,6 +266,205 @@ public class GitService
|
||||
}
|
||||
}
|
||||
|
||||
public List<CommitInfo> listCommits(String name) throws IOException
|
||||
{
|
||||
try (Git git = openRepository(name))
|
||||
{
|
||||
Repository repo = git.getRepository();
|
||||
try (PlotWalk plotWalk = new PlotWalk(repo))
|
||||
{
|
||||
ObjectId head = repo.resolve("HEAD");
|
||||
if (head == null)
|
||||
return List.of();
|
||||
plotWalk.markStart(plotWalk.parseCommit(head));
|
||||
|
||||
PlotCommitList<PlotLane> plotCommitList = new PlotCommitList<>();
|
||||
plotCommitList.source(plotWalk);
|
||||
plotCommitList.fillTo(Integer.MAX_VALUE);
|
||||
|
||||
int maxLanes = 0;
|
||||
for (PlotCommit<PlotLane> pc : plotCommitList)
|
||||
{
|
||||
if (pc.getLane() != null && pc.getLane().getPosition() + 1 > maxLanes)
|
||||
maxLanes = pc.getLane().getPosition() + 1;
|
||||
}
|
||||
|
||||
List<CommitInfo> commits = new ArrayList<>();
|
||||
for (PlotCommit<PlotLane> pc : plotCommitList)
|
||||
{
|
||||
String graphLine = buildGraphLine(pc, maxLanes);
|
||||
List<String> parents = Arrays.stream(pc.getParents())
|
||||
.map(p -> p.getId().abbreviate(7).name())
|
||||
.toList();
|
||||
|
||||
DateTimeFormatter fmt = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm")
|
||||
.withZone(ZoneId.systemDefault());
|
||||
|
||||
commits.add(new CommitInfo(
|
||||
pc.getId().getName(),
|
||||
pc.getId().abbreviate(7).name(),
|
||||
pc.getShortMessage(),
|
||||
pc.getAuthorIdent().getName(),
|
||||
fmt.format(Instant.ofEpochSecond(pc.getCommitTime())),
|
||||
parents,
|
||||
graphLine
|
||||
));
|
||||
}
|
||||
return commits;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private String buildGraphLine(PlotCommit<PlotLane> pc, int maxLanes)
|
||||
{
|
||||
int lanePos = pc.getLane() != null ? pc.getLane().getPosition() : 0;
|
||||
int width = Math.max(maxLanes, lanePos + 1);
|
||||
char[] line = new char[width];
|
||||
Arrays.fill(line, ' ');
|
||||
|
||||
// Mark passing-through lanes
|
||||
for (int i = 0; i < pc.getChildCount(); i++)
|
||||
{
|
||||
PlotCommit child = (PlotCommit) pc.getChild(i);
|
||||
if (child.getLane() != null)
|
||||
{
|
||||
int childPos = child.getLane().getPosition();
|
||||
if (childPos < width)
|
||||
line[childPos] = '|';
|
||||
}
|
||||
}
|
||||
|
||||
line[lanePos] = '*';
|
||||
return new String(line);
|
||||
}
|
||||
|
||||
public List<DiffInfo> getCommitDiff(String name, String commitHash) throws IOException
|
||||
{
|
||||
try (Git git = openRepository(name))
|
||||
{
|
||||
Repository repo = git.getRepository();
|
||||
ObjectId commitId = repo.resolve(commitHash);
|
||||
try (var walk = new org.eclipse.jgit.revwalk.RevWalk(repo))
|
||||
{
|
||||
RevCommit commit = walk.parseCommit(commitId);
|
||||
RevTree newTree = commit.getTree();
|
||||
RevTree oldTree = null;
|
||||
if (commit.getParentCount() > 0)
|
||||
{
|
||||
RevCommit parent = walk.parseCommit(commit.getParent(0));
|
||||
oldTree = parent.getTree();
|
||||
}
|
||||
|
||||
ByteArrayOutputStream out = new ByteArrayOutputStream();
|
||||
try (DiffFormatter df = new DiffFormatter(out))
|
||||
{
|
||||
df.setRepository(repo);
|
||||
List<DiffEntry> diffs = df.scan(oldTree, newTree);
|
||||
List<DiffInfo> result = new ArrayList<>();
|
||||
for (DiffEntry diff : diffs)
|
||||
{
|
||||
out.reset();
|
||||
df.format(diff);
|
||||
result.add(new DiffInfo(
|
||||
diff.getChangeType().name(),
|
||||
diff.getOldPath(),
|
||||
diff.getNewPath(),
|
||||
out.toString(StandardCharsets.UTF_8)
|
||||
));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public List<FileInfo> listFilesAtCommit(String name, String commitHash, String dirPath) throws IOException
|
||||
{
|
||||
try (Git git = openRepository(name))
|
||||
{
|
||||
Repository repo = git.getRepository();
|
||||
ObjectId commitId = repo.resolve(commitHash);
|
||||
try (var walk = new org.eclipse.jgit.revwalk.RevWalk(repo))
|
||||
{
|
||||
RevCommit commit = walk.parseCommit(commitId);
|
||||
RevTree tree = commit.getTree();
|
||||
|
||||
List<FileInfo> files = new ArrayList<>();
|
||||
try (TreeWalk tw = new TreeWalk(repo))
|
||||
{
|
||||
tw.addTree(tree);
|
||||
tw.setRecursive(false);
|
||||
if (dirPath != null && !dirPath.isEmpty())
|
||||
{
|
||||
tw.setFilter(PathFilter.create(dirPath));
|
||||
// Walk into the directory
|
||||
while (tw.next())
|
||||
{
|
||||
if (tw.isSubtree() && tw.getPathString().equals(dirPath))
|
||||
{
|
||||
tw.enterSubtree();
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
while (tw.next())
|
||||
{
|
||||
if (dirPath != null && !dirPath.isEmpty() && !tw.getPathString().startsWith(dirPath + "/"))
|
||||
continue;
|
||||
String type = tw.isSubtree() ? "tree" : "blob";
|
||||
files.add(new FileInfo(tw.getPathString(), type));
|
||||
}
|
||||
}
|
||||
return files;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public String getFileContentAtCommit(String name, String commitHash, String filePath) throws IOException
|
||||
{
|
||||
try (Git git = openRepository(name))
|
||||
{
|
||||
Repository repo = git.getRepository();
|
||||
ObjectId commitId = repo.resolve(commitHash);
|
||||
try (var walk = new org.eclipse.jgit.revwalk.RevWalk(repo))
|
||||
{
|
||||
RevCommit commit = walk.parseCommit(commitId);
|
||||
RevTree tree = commit.getTree();
|
||||
|
||||
try (TreeWalk tw = TreeWalk.forPath(repo, filePath, tree))
|
||||
{
|
||||
if (tw == null)
|
||||
return null;
|
||||
ObjectLoader loader = repo.open(tw.getObjectId(0));
|
||||
return new String(loader.getBytes(), StandardCharsets.UTF_8);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void checkoutCommit(String name, String commitHash) throws IOException, GitAPIException
|
||||
{
|
||||
try (Git git = openRepository(name))
|
||||
{
|
||||
git.checkout()
|
||||
.setName(commitHash)
|
||||
.call();
|
||||
}
|
||||
}
|
||||
|
||||
public void checkoutFilesFromCommit(String name, String commitHash, List<String> files) throws IOException, GitAPIException
|
||||
{
|
||||
try (Git git = openRepository(name))
|
||||
{
|
||||
var checkout = git.checkout().setStartPoint(commitHash);
|
||||
for (String file : files)
|
||||
{
|
||||
checkout.addPath(file);
|
||||
}
|
||||
checkout.call();
|
||||
}
|
||||
}
|
||||
|
||||
public void push(String name) throws IOException, GitAPIException
|
||||
{
|
||||
try (Git git = openRepository(name))
|
||||
|
||||
31
src/main/resources/templates/blob.html
Normal file
31
src/main/resources/templates/blob.html
Normal file
@@ -0,0 +1,31 @@
|
||||
<!DOCTYPE html>
|
||||
<html xmlns:th="http://www.thymeleaf.org">
|
||||
<head>
|
||||
<title th:text="'File - ' + ${filePath}">File</title>
|
||||
</head>
|
||||
<body>
|
||||
<h2 th:text="${filePath}">file.txt</h2>
|
||||
|
||||
<p>
|
||||
<a th:href="@{/repo/{name}/tree/{hash}(name=${name}, hash=${hash})}">< Back to tree</a>
|
||||
|
|
||||
<a th:href="@{/repo/{name}/commits(name=${name})}">< Back to commits</a>
|
||||
</p>
|
||||
|
||||
<table border="0" cellpadding="4" cellspacing="0">
|
||||
<tr>
|
||||
<td>
|
||||
<form method="post" th:action="@{/repo/{name}/checkout-files(name=${name})}">
|
||||
<input type="hidden" name="hash" th:value="${hash}">
|
||||
<input type="hidden" name="files" th:value="${filePath}">
|
||||
<input type="submit" value="Checkout this file">
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<h3>Content</h3>
|
||||
<pre th:text="${content}">File content here</pre>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
52
src/main/resources/templates/commit.html
Normal file
52
src/main/resources/templates/commit.html
Normal file
@@ -0,0 +1,52 @@
|
||||
<!DOCTYPE html>
|
||||
<html xmlns:th="http://www.thymeleaf.org">
|
||||
<head>
|
||||
<title th:text="'Commit ' + ${hash}">Commit</title>
|
||||
</head>
|
||||
<body>
|
||||
<h2>Commit <span th:text="${shortHash}"></span></h2>
|
||||
|
||||
<p><a th:href="@{/repo/{name}/commits(name=${name})}">< Back to commits</a></p>
|
||||
|
||||
<table border="0" cellpadding="4" cellspacing="0">
|
||||
<tr>
|
||||
<td>
|
||||
<form method="post" th:action="@{/repo/{name}/checkout-commit(name=${name})}">
|
||||
<input type="hidden" name="hash" th:value="${hash}">
|
||||
<input type="submit" value="Checkout this commit">
|
||||
</form>
|
||||
</td>
|
||||
<td>
|
||||
<a th:href="@{/repo/{name}/tree/{hash}(name=${name}, hash=${hash})}">Browse files</a>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<h3>Changed Files</h3>
|
||||
|
||||
<form method="post" th:action="@{/repo/{name}/checkout-files(name=${name})}">
|
||||
<input type="hidden" name="hash" th:value="${hash}">
|
||||
<table border="1" cellpadding="4" cellspacing="0">
|
||||
<tr>
|
||||
<th></th>
|
||||
<th>Change</th>
|
||||
<th>File</th>
|
||||
</tr>
|
||||
<tr th:each="d : ${diffs}">
|
||||
<td><input type="checkbox" name="files" th:value="${d.newPath}"></td>
|
||||
<td th:text="${d.changeType}"></td>
|
||||
<td th:text="${d.changeType == 'DELETE' ? d.oldPath : d.newPath}"></td>
|
||||
</tr>
|
||||
</table>
|
||||
<br>
|
||||
<input type="submit" value="Checkout selected files">
|
||||
</form>
|
||||
|
||||
<h3>Diff</h3>
|
||||
<th:block th:each="d : ${diffs}">
|
||||
<h4 th:text="${d.changeType == 'DELETE' ? d.oldPath : d.newPath}"></h4>
|
||||
<pre th:text="${d.diff}"></pre>
|
||||
</th:block>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
40
src/main/resources/templates/commits.html
Normal file
40
src/main/resources/templates/commits.html
Normal file
@@ -0,0 +1,40 @@
|
||||
<!DOCTYPE html>
|
||||
<html xmlns:th="http://www.thymeleaf.org">
|
||||
<head>
|
||||
<title th:text="'Commits - ' + ${name}">Commits</title>
|
||||
</head>
|
||||
<body>
|
||||
<h2>Commits</h2>
|
||||
|
||||
<table border="1" cellpadding="4" cellspacing="0">
|
||||
<tr>
|
||||
<th>Graph</th>
|
||||
<th>Hash</th>
|
||||
<th>Message</th>
|
||||
<th>Author</th>
|
||||
<th>Date</th>
|
||||
<th></th>
|
||||
<th></th>
|
||||
<th></th>
|
||||
</tr>
|
||||
<tr th:each="c : ${commits}">
|
||||
<td><pre th:text="${c.graphLine}" style="margin:0"></pre></td>
|
||||
<td><a th:href="@{/repo/{name}/commit/{hash}(name=${name}, hash=${c.hash})}" th:text="${c.shortHash}"></a></td>
|
||||
<td th:text="${c.message}"></td>
|
||||
<td th:text="${c.author}"></td>
|
||||
<td th:text="${c.date}"></td>
|
||||
<td><a th:href="@{/repo/{name}/tree/{hash}(name=${name}, hash=${c.hash})}">Browse</a></td>
|
||||
<td>
|
||||
<form method="post" th:action="@{/repo/{name}/checkout-commit(name=${name})}">
|
||||
<input type="hidden" name="hash" th:value="${c.hash}">
|
||||
<input type="submit" value="Checkout">
|
||||
</form>
|
||||
</td>
|
||||
<td><a th:href="@{/repo/{name}/commit/{hash}(name=${name}, hash=${c.hash})}">Diff</a></td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<p th:if="${commits.isEmpty()}">No commits yet.</p>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
@@ -20,6 +20,7 @@
|
||||
<hr>
|
||||
<b th:text="${selectedRepo}"></b><br>
|
||||
<a th:href="@{/repo/{name}/changes(name=${selectedRepo})}" target="content">Staging</a><br>
|
||||
<a th:href="@{/repo/{name}/commits(name=${selectedRepo})}" target="content">Commits</a><br>
|
||||
<a th:href="@{/repo/{name}/branches(name=${selectedRepo})}" target="content">Branches</a><br>
|
||||
<a th:href="@{/repo/{name}/remote(name=${selectedRepo})}" target="content">Remote</a><br>
|
||||
<a th:href="@{/repo/{name}/manage(name=${selectedRepo})}" target="content">Manage</a><br>
|
||||
|
||||
34
src/main/resources/templates/tree.html
Normal file
34
src/main/resources/templates/tree.html
Normal file
@@ -0,0 +1,34 @@
|
||||
<!DOCTYPE html>
|
||||
<html xmlns:th="http://www.thymeleaf.org">
|
||||
<head>
|
||||
<title th:text="'Browse - ' + ${shortHash}">Browse</title>
|
||||
</head>
|
||||
<body>
|
||||
<h2>Browse <span th:text="${shortHash}"></span></h2>
|
||||
|
||||
<p><a th:href="@{/repo/{name}/commits(name=${name})}">< Back to commits</a></p>
|
||||
|
||||
<p th:if="${!path.isEmpty()}">
|
||||
Path: <b th:text="${path}"></b>
|
||||
<br>
|
||||
<a th:href="@{/repo/{name}/tree/{hash}(name=${name}, hash=${hash}, path=${path.contains('/') ? path.substring(0, path.lastIndexOf('/')) : ''})}">< Parent directory</a>
|
||||
</p>
|
||||
|
||||
<table border="1" cellpadding="4" cellspacing="0">
|
||||
<tr>
|
||||
<th>Type</th>
|
||||
<th>Name</th>
|
||||
</tr>
|
||||
<tr th:each="f : ${files}">
|
||||
<td th:text="${f.type}"></td>
|
||||
<td>
|
||||
<a th:if="${f.type == 'tree'}" th:href="@{/repo/{name}/tree/{hash}(name=${name}, hash=${hash}, path=${f.path})}" th:text="${f.path.contains('/') ? f.path.substring(f.path.lastIndexOf('/') + 1) : f.path}"></a>
|
||||
<a th:if="${f.type == 'blob'}" th:href="@{'/repo/' + ${name} + '/blob/' + ${hash} + '/' + ${f.path}}" th:text="${f.path.contains('/') ? f.path.substring(f.path.lastIndexOf('/') + 1) : f.path}"></a>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<p th:if="${files.isEmpty()}">Empty directory.</p>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
@@ -213,4 +213,81 @@ class RepoControllerTest
|
||||
|
||||
verify(gitService).deleteRepository("myrepo");
|
||||
}
|
||||
|
||||
@Test
|
||||
void commitsPageShowsCommits() throws Exception
|
||||
{
|
||||
when(gitService.listCommits("myrepo")).thenReturn(List.of(
|
||||
new be.seeseepuff.webgit.model.CommitInfo("abc1234567890", "abc1234", "Initial commit", "author", "2026-01-01 12:00", List.of(), "*")
|
||||
));
|
||||
|
||||
mockMvc.perform(get("/repo/myrepo/commits"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(view().name("commits"))
|
||||
.andExpect(model().attribute("name", "myrepo"))
|
||||
.andExpect(content().string(org.hamcrest.Matchers.containsString("abc1234")))
|
||||
.andExpect(content().string(org.hamcrest.Matchers.containsString("Initial commit")));
|
||||
}
|
||||
|
||||
@Test
|
||||
void commitDetailShowsDiff() throws Exception
|
||||
{
|
||||
when(gitService.getCommitDiff("myrepo", "abc1234")).thenReturn(List.of(
|
||||
new be.seeseepuff.webgit.model.DiffInfo("ADD", "/dev/null", "file.txt", "+hello")
|
||||
));
|
||||
|
||||
mockMvc.perform(get("/repo/myrepo/commit/abc1234"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(view().name("commit"))
|
||||
.andExpect(model().attribute("hash", "abc1234"))
|
||||
.andExpect(content().string(org.hamcrest.Matchers.containsString("file.txt")));
|
||||
}
|
||||
|
||||
@Test
|
||||
void treeShowsFiles() throws Exception
|
||||
{
|
||||
when(gitService.listFilesAtCommit("myrepo", "abc1234", "")).thenReturn(List.of(
|
||||
new be.seeseepuff.webgit.model.FileInfo("README.md", "blob")
|
||||
));
|
||||
|
||||
mockMvc.perform(get("/repo/myrepo/tree/abc1234"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(view().name("tree"))
|
||||
.andExpect(content().string(org.hamcrest.Matchers.containsString("README.md")));
|
||||
}
|
||||
|
||||
@Test
|
||||
void blobShowsFileContent() throws Exception
|
||||
{
|
||||
when(gitService.getFileContentAtCommit("myrepo", "abc1234", "README.md")).thenReturn("# Hello");
|
||||
|
||||
mockMvc.perform(get("/repo/myrepo/blob/abc1234/README.md"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(view().name("blob"))
|
||||
.andExpect(content().string(org.hamcrest.Matchers.containsString("# Hello")));
|
||||
}
|
||||
|
||||
@Test
|
||||
void checkoutCommitRedirectsToCommits() throws Exception
|
||||
{
|
||||
mockMvc.perform(post("/repo/myrepo/checkout-commit")
|
||||
.param("hash", "abc1234"))
|
||||
.andExpect(status().is3xxRedirection())
|
||||
.andExpect(redirectedUrl("/repo/myrepo/commits"));
|
||||
|
||||
verify(gitService).checkoutCommit("myrepo", "abc1234");
|
||||
}
|
||||
|
||||
@Test
|
||||
void checkoutFilesRedirectsToChanges() throws Exception
|
||||
{
|
||||
mockMvc.perform(post("/repo/myrepo/checkout-files")
|
||||
.param("hash", "abc1234")
|
||||
.param("files", "a.txt")
|
||||
.param("files", "b.txt"))
|
||||
.andExpect(status().is3xxRedirection())
|
||||
.andExpect(redirectedUrl("/repo/myrepo/changes"));
|
||||
|
||||
verify(gitService).checkoutFilesFromCommit("myrepo", "abc1234", List.of("a.txt", "b.txt"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -333,4 +333,90 @@ class GitServiceTest
|
||||
var remotes = gitService.listRemotes("myrepo");
|
||||
assertEquals("https://new-url.com/repo.git", remotes.get("origin"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void listCommitsReturnsCommitHistory() throws GitAPIException, IOException
|
||||
{
|
||||
gitService.cloneRepository(bareRemote.toUri().toString(), "myrepo");
|
||||
|
||||
var commits = gitService.listCommits("myrepo");
|
||||
assertFalse(commits.isEmpty());
|
||||
assertEquals("Initial commit", commits.getFirst().message());
|
||||
assertNotNull(commits.getFirst().hash());
|
||||
assertNotNull(commits.getFirst().shortHash());
|
||||
assertNotNull(commits.getFirst().author());
|
||||
assertNotNull(commits.getFirst().date());
|
||||
assertNotNull(commits.getFirst().graphLine());
|
||||
}
|
||||
|
||||
@Test
|
||||
void getCommitDiffReturnsDiffs() throws GitAPIException, IOException
|
||||
{
|
||||
gitService.cloneRepository(bareRemote.toUri().toString(), "myrepo");
|
||||
|
||||
var commits = gitService.listCommits("myrepo");
|
||||
var diffs = gitService.getCommitDiff("myrepo", commits.getFirst().hash());
|
||||
assertFalse(diffs.isEmpty());
|
||||
assertEquals("ADD", diffs.getFirst().changeType());
|
||||
assertEquals("README.md", diffs.getFirst().newPath());
|
||||
assertFalse(diffs.getFirst().diff().isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void listFilesAtCommitReturnsFiles() throws GitAPIException, IOException
|
||||
{
|
||||
gitService.cloneRepository(bareRemote.toUri().toString(), "myrepo");
|
||||
|
||||
var commits = gitService.listCommits("myrepo");
|
||||
var files = gitService.listFilesAtCommit("myrepo", commits.getFirst().hash(), "");
|
||||
assertFalse(files.isEmpty());
|
||||
assertTrue(files.stream().anyMatch(f -> f.path().equals("README.md")));
|
||||
}
|
||||
|
||||
@Test
|
||||
void getFileContentAtCommitReturnsContent() throws GitAPIException, IOException
|
||||
{
|
||||
gitService.cloneRepository(bareRemote.toUri().toString(), "myrepo");
|
||||
|
||||
var commits = gitService.listCommits("myrepo");
|
||||
String content = gitService.getFileContentAtCommit("myrepo", commits.getFirst().hash(), "README.md");
|
||||
assertEquals("# Test", content);
|
||||
}
|
||||
|
||||
@Test
|
||||
void getFileContentAtCommitReturnsNullForMissingFile() throws GitAPIException, IOException
|
||||
{
|
||||
gitService.cloneRepository(bareRemote.toUri().toString(), "myrepo");
|
||||
|
||||
var commits = gitService.listCommits("myrepo");
|
||||
String content = gitService.getFileContentAtCommit("myrepo", commits.getFirst().hash(), "nonexistent.txt");
|
||||
assertNull(content);
|
||||
}
|
||||
|
||||
@Test
|
||||
void checkoutCommitDetachesHead() throws GitAPIException, IOException
|
||||
{
|
||||
gitService.cloneRepository(bareRemote.toUri().toString(), "myrepo");
|
||||
|
||||
var commits = gitService.listCommits("myrepo");
|
||||
gitService.checkoutCommit("myrepo", commits.getFirst().hash());
|
||||
|
||||
String head = gitService.getCurrentBranch("myrepo");
|
||||
// Detached HEAD returns the commit hash
|
||||
assertTrue(head.matches("[0-9a-f]+"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void checkoutFilesFromCommitRestoresFiles() throws GitAPIException, IOException
|
||||
{
|
||||
gitService.cloneRepository(bareRemote.toUri().toString(), "myrepo");
|
||||
// Modify a file
|
||||
Path readme = worktreePath.resolve("myrepo").resolve("README.md");
|
||||
Files.writeString(readme, "modified");
|
||||
|
||||
var commits = gitService.listCommits("myrepo");
|
||||
gitService.checkoutFilesFromCommit("myrepo", commits.getFirst().hash(), List.of("README.md"));
|
||||
|
||||
assertEquals("# Test", Files.readString(readme));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user