Compare commits
10 Commits
fbfffac73f
...
98de13b410
| Author | SHA1 | Date | |
|---|---|---|---|
| 98de13b410 | |||
| 8784dfc391 | |||
| b0016767e8 | |||
| 04a69c323e | |||
| eb222716cd | |||
| d68933bc2f | |||
| b0c869829f | |||
| 321e268530 | |||
| 1b6f007eea | |||
| b2b3993d85 |
21
.gitea/workflows/build.yml
Normal file
21
.gitea/workflows/build.yml
Normal file
@@ -0,0 +1,21 @@
|
||||
name: Build
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- '*'
|
||||
jobs:
|
||||
build:
|
||||
runs-on: standard-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Java
|
||||
uses: actions/setup-java@v4
|
||||
with:
|
||||
distribution: 'temurin'
|
||||
java-version: '25'
|
||||
cache: 'gradle'
|
||||
|
||||
- name: Build
|
||||
run: ./gradlew build --no-daemon
|
||||
32
.gitea/workflows/deploy.yml
Normal file
32
.gitea/workflows/deploy.yml
Normal file
@@ -0,0 +1,32 @@
|
||||
name: Deploy
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
jobs:
|
||||
build:
|
||||
runs-on: standard-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Java
|
||||
uses: actions/setup-java@v4
|
||||
with:
|
||||
distribution: 'temurin'
|
||||
java-version: '25'
|
||||
cache: 'gradle'
|
||||
|
||||
- name: Build Jar
|
||||
run: ./gradlew bootJar
|
||||
|
||||
- name: Build Container
|
||||
run: docker build --tag gitea.seeseepuff.be/seeseemelk/webgit:${{github.ref_name}} .
|
||||
|
||||
- name: Login
|
||||
with:
|
||||
package_rw: ${{ secrets.PACKAGE_RW }}
|
||||
run: docker login gitea.seeseepuff.be -u seeseemelk -p ${{ secrets.PACKAGE_RW }}
|
||||
|
||||
- name: Push Container
|
||||
run: docker push gitea.seeseepuff.be/seeseemelk/webgit:${{github.ref_name}}
|
||||
5
Dockerfile
Normal file
5
Dockerfile
Normal file
@@ -0,0 +1,5 @@
|
||||
FROM eclipse-temurin:25-alpine
|
||||
WORKDIR /app
|
||||
ADD ./build/libs/webgit-0.0.1-SNAPSHOT.jar /app/webgit.jar
|
||||
ENTRYPOINT ["java", "-jar", "webgit.jar"]
|
||||
EXPOSE 8080/tcp
|
||||
@@ -3,6 +3,8 @@ package be.seeseepuff.webgit.controller;
|
||||
import be.seeseepuff.webgit.service.GitService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.eclipse.jgit.api.errors.GitAPIException;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.stereotype.Controller;
|
||||
import org.springframework.ui.Model;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
@@ -12,11 +14,25 @@ import org.springframework.web.bind.annotation.RequestParam;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
@Controller
|
||||
@RequiredArgsConstructor
|
||||
public class RepoController
|
||||
{
|
||||
private static final Set<String> IMAGE_EXTENSIONS = Set.of("png", "jpg", "jpeg", "gif", "bmp", "webp", "svg", "ico");
|
||||
private static final Map<String, String> IMAGE_MIME_TYPES = Map.of(
|
||||
"png", "image/png",
|
||||
"jpg", "image/jpeg",
|
||||
"jpeg", "image/jpeg",
|
||||
"gif", "image/gif",
|
||||
"bmp", "image/bmp",
|
||||
"webp", "image/webp",
|
||||
"svg", "image/svg+xml",
|
||||
"ico", "image/x-icon"
|
||||
);
|
||||
|
||||
private final GitService gitService;
|
||||
|
||||
@GetMapping("/repo/{name}")
|
||||
@@ -38,11 +54,25 @@ public class RepoController
|
||||
public String changes(@PathVariable String name, Model model) throws IOException, GitAPIException
|
||||
{
|
||||
model.addAttribute("name", name);
|
||||
model.addAttribute("branch", gitService.getCurrentBranch(name));
|
||||
model.addAttribute("modifiedFiles", gitService.getModifiedFiles(name));
|
||||
model.addAttribute("stagedFiles", gitService.getStagedFiles(name));
|
||||
return "changes";
|
||||
}
|
||||
|
||||
@GetMapping("/repo/{name}/diff/{filePath}/**")
|
||||
public String fileDiff(@PathVariable String name,
|
||||
jakarta.servlet.http.HttpServletRequest request, Model model) throws IOException
|
||||
{
|
||||
String fullPath = request.getRequestURI();
|
||||
String prefix = "/repo/" + name + "/diff/";
|
||||
String filePath = fullPath.substring(prefix.length());
|
||||
model.addAttribute("name", name);
|
||||
model.addAttribute("filePath", filePath);
|
||||
model.addAttribute("diff", gitService.getWorkingTreeDiff(name, filePath));
|
||||
return "file-diff";
|
||||
}
|
||||
|
||||
@GetMapping("/repo/{name}/remote")
|
||||
public String remote(@PathVariable String name, Model model) throws IOException
|
||||
{
|
||||
@@ -73,6 +103,14 @@ public class RepoController
|
||||
model.addAttribute("name", name);
|
||||
model.addAttribute("hash", hash);
|
||||
model.addAttribute("shortHash", hash.substring(0, Math.min(7, hash.length())));
|
||||
var info = gitService.getCommitInfo(name, hash);
|
||||
model.addAttribute("commitInfo", info);
|
||||
String fullMsg = info.message() != null ? info.message().trim() : "";
|
||||
int newline = fullMsg.indexOf('\n');
|
||||
String title = newline >= 0 ? fullMsg.substring(0, newline).trim() : fullMsg;
|
||||
String body = newline >= 0 ? fullMsg.substring(newline).trim() : "";
|
||||
model.addAttribute("commitTitle", title);
|
||||
model.addAttribute("commitBody", body);
|
||||
model.addAttribute("diffs", gitService.getCommitDiff(name, hash));
|
||||
return "commit";
|
||||
}
|
||||
@@ -85,6 +123,7 @@ public class RepoController
|
||||
model.addAttribute("hash", hash);
|
||||
model.addAttribute("shortHash", hash.substring(0, Math.min(7, hash.length())));
|
||||
model.addAttribute("path", path);
|
||||
model.addAttribute("parentPath", path.contains("/") ? path.substring(0, path.lastIndexOf('/')) : "");
|
||||
model.addAttribute("files", gitService.listFilesAtCommit(name, hash, path));
|
||||
return "tree";
|
||||
}
|
||||
@@ -96,13 +135,34 @@ public class RepoController
|
||||
String fullPath = request.getRequestURI();
|
||||
String prefix = "/repo/" + name + "/blob/" + hash + "/";
|
||||
String filePath = fullPath.substring(prefix.length());
|
||||
String ext = filePath.contains(".") ? filePath.substring(filePath.lastIndexOf('.') + 1).toLowerCase() : "";
|
||||
boolean isImage = IMAGE_EXTENSIONS.contains(ext);
|
||||
String parentPath = filePath.contains("/") ? filePath.substring(0, filePath.lastIndexOf('/')) : "";
|
||||
model.addAttribute("name", name);
|
||||
model.addAttribute("hash", hash);
|
||||
model.addAttribute("filePath", filePath);
|
||||
model.addAttribute("content", gitService.getFileContentAtCommit(name, hash, filePath));
|
||||
model.addAttribute("parentPath", parentPath);
|
||||
model.addAttribute("isImage", isImage);
|
||||
if (!isImage)
|
||||
model.addAttribute("content", gitService.getFileContentAtCommit(name, hash, filePath));
|
||||
return "blob";
|
||||
}
|
||||
|
||||
@GetMapping("/repo/{name}/raw/{hash}/**")
|
||||
public ResponseEntity<byte[]> rawFile(@PathVariable String name, @PathVariable String hash,
|
||||
jakarta.servlet.http.HttpServletRequest request) throws IOException
|
||||
{
|
||||
String fullPath = request.getRequestURI();
|
||||
String prefix = "/repo/" + name + "/raw/" + hash + "/";
|
||||
String filePath = fullPath.substring(prefix.length());
|
||||
byte[] bytes = gitService.getRawFileAtCommit(name, hash, filePath);
|
||||
if (bytes == null)
|
||||
return ResponseEntity.notFound().build();
|
||||
String ext = filePath.contains(".") ? filePath.substring(filePath.lastIndexOf('.') + 1).toLowerCase() : "";
|
||||
String mimeType = IMAGE_MIME_TYPES.getOrDefault(ext, MediaType.APPLICATION_OCTET_STREAM_VALUE);
|
||||
return ResponseEntity.ok().contentType(MediaType.parseMediaType(mimeType)).body(bytes);
|
||||
}
|
||||
|
||||
@PostMapping("/repo/{name}/checkout-commit")
|
||||
public String checkoutCommit(@PathVariable String name, @RequestParam String hash) throws IOException, GitAPIException
|
||||
{
|
||||
@@ -133,9 +193,13 @@ public class RepoController
|
||||
}
|
||||
|
||||
@PostMapping("/repo/{name}/stage")
|
||||
public String stage(@PathVariable String name, @RequestParam List<String> files) throws IOException, GitAPIException
|
||||
public String stage(@PathVariable String name, @RequestParam List<String> files,
|
||||
@RequestParam(defaultValue = "Stage Selected") String action) throws IOException, GitAPIException
|
||||
{
|
||||
gitService.stageFiles(name, files);
|
||||
if ("Rollback Selected".equals(action))
|
||||
gitService.rollbackFiles(name, files);
|
||||
else
|
||||
gitService.stageFiles(name, files);
|
||||
return "redirect:/repo/" + name + "/changes";
|
||||
}
|
||||
|
||||
|
||||
@@ -36,8 +36,10 @@ import java.time.format.DateTimeFormatter;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
@Service
|
||||
@@ -209,6 +211,45 @@ public class GitService
|
||||
}
|
||||
}
|
||||
|
||||
public String getWorkingTreeDiff(String name, String filePath) throws IOException
|
||||
{
|
||||
try (Git git = openRepository(name))
|
||||
{
|
||||
Repository repo = git.getRepository();
|
||||
ByteArrayOutputStream out = new ByteArrayOutputStream();
|
||||
try (DiffFormatter df = new DiffFormatter(out))
|
||||
{
|
||||
df.setRepository(repo);
|
||||
df.setPathFilter(PathFilter.create(filePath));
|
||||
// Unstaged: index vs working tree
|
||||
var dirCache = new org.eclipse.jgit.dircache.DirCacheIterator(repo.readDirCache());
|
||||
var workTree = new org.eclipse.jgit.treewalk.FileTreeIterator(repo);
|
||||
List<DiffEntry> unstaged = df.scan(dirCache, workTree);
|
||||
for (DiffEntry diff : unstaged)
|
||||
{
|
||||
df.format(diff);
|
||||
}
|
||||
// Staged: HEAD vs index
|
||||
ObjectId headTree = repo.resolve("HEAD^{tree}");
|
||||
if (headTree != null)
|
||||
{
|
||||
var headIter = new org.eclipse.jgit.treewalk.CanonicalTreeParser();
|
||||
try (var reader = repo.newObjectReader())
|
||||
{
|
||||
headIter.reset(reader, headTree);
|
||||
}
|
||||
var indexIter = new org.eclipse.jgit.dircache.DirCacheIterator(repo.readDirCache());
|
||||
List<DiffEntry> staged = df.scan(headIter, indexIter);
|
||||
for (DiffEntry diff : staged)
|
||||
{
|
||||
df.format(diff);
|
||||
}
|
||||
}
|
||||
}
|
||||
return out.toString(StandardCharsets.UTF_8);
|
||||
}
|
||||
}
|
||||
|
||||
public void stageFiles(String name, List<String> files) throws IOException, GitAPIException
|
||||
{
|
||||
try (Git git = openRepository(name))
|
||||
@@ -227,6 +268,19 @@ public class GitService
|
||||
}
|
||||
}
|
||||
|
||||
public void rollbackFiles(String name, List<String> files) throws IOException, GitAPIException
|
||||
{
|
||||
try (Git git = openRepository(name))
|
||||
{
|
||||
var checkout = git.checkout().setStartPoint("HEAD");
|
||||
for (String file : files)
|
||||
{
|
||||
checkout.addPath(file);
|
||||
}
|
||||
checkout.call();
|
||||
}
|
||||
}
|
||||
|
||||
public void commit(String name, String message) throws IOException, GitAPIException
|
||||
{
|
||||
try (Git git = openRepository(name))
|
||||
@@ -297,17 +351,25 @@ public class GitService
|
||||
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;
|
||||
}
|
||||
// Track which lane positions are currently "open" (started but parent not yet seen)
|
||||
Set<Integer> openLanes = new LinkedHashSet<>();
|
||||
|
||||
List<CommitInfo> commits = new ArrayList<>();
|
||||
for (PlotCommit<PlotLane> pc : plotCommitList)
|
||||
{
|
||||
String graphLine = buildGraphLine(pc, maxLanes);
|
||||
int lane = pc.getLane() != null ? pc.getLane().getPosition() : 0;
|
||||
openLanes.add(lane); // ensure this lane is marked active
|
||||
|
||||
String graphLine = buildGraphLine(pc, new LinkedHashSet<>(openLanes));
|
||||
|
||||
// Advance lane state: close this lane, open parent lanes
|
||||
openLanes.remove(lane);
|
||||
for (RevCommit parent : pc.getParents())
|
||||
{
|
||||
if (parent instanceof PlotCommit<?> pp && pp.getLane() != null)
|
||||
openLanes.add(pp.getLane().getPosition());
|
||||
}
|
||||
|
||||
List<String> parents = Arrays.stream(pc.getParents())
|
||||
.map(p -> p.getId().abbreviate(7).name())
|
||||
.toList();
|
||||
@@ -330,27 +392,73 @@ public class GitService
|
||||
}
|
||||
}
|
||||
|
||||
private String buildGraphLine(PlotCommit<PlotLane> pc, int maxLanes)
|
||||
private String buildGraphLine(PlotCommit<PlotLane> pc, Set<Integer> activeLanes)
|
||||
{
|
||||
int lanePos = pc.getLane() != null ? pc.getLane().getPosition() : 0;
|
||||
int width = Math.max(maxLanes, lanePos + 1);
|
||||
char[] line = new char[width];
|
||||
Arrays.fill(line, ' ');
|
||||
int commitLane = pc.getLane() != null ? pc.getLane().getPosition() : 0;
|
||||
|
||||
// Mark passing-through lanes
|
||||
for (int i = 0; i < pc.getChildCount(); i++)
|
||||
// Find the leftmost parent lane that is to the left of this commit's lane.
|
||||
// Dashes are drawn from that lane to this commit to show convergence.
|
||||
int mergeFromLane = commitLane;
|
||||
for (RevCommit parent : pc.getParents())
|
||||
{
|
||||
PlotCommit child = (PlotCommit) pc.getChild(i);
|
||||
if (child.getLane() != null)
|
||||
if (parent instanceof PlotCommit<?> pp && pp.getLane() != null)
|
||||
{
|
||||
int childPos = child.getLane().getPosition();
|
||||
if (childPos < width)
|
||||
line[childPos] = '|';
|
||||
int pLane = pp.getLane().getPosition();
|
||||
if (pLane < commitLane)
|
||||
mergeFromLane = Math.min(mergeFromLane, pLane);
|
||||
}
|
||||
}
|
||||
|
||||
line[lanePos] = '*';
|
||||
return new String(line);
|
||||
int width = activeLanes.stream().mapToInt(Integer::intValue).max().orElse(0) + 1;
|
||||
|
||||
StringBuilder sb = new StringBuilder();
|
||||
for (int i = 0; i < width; i++)
|
||||
{
|
||||
if (i == commitLane)
|
||||
sb.append('*');
|
||||
else if (activeLanes.contains(i))
|
||||
sb.append('|');
|
||||
else
|
||||
sb.append(' ');
|
||||
|
||||
if (i < width - 1)
|
||||
{
|
||||
// Draw '-' between mergeFromLane and commitLane to show branch convergence
|
||||
if (i >= mergeFromLane && i < commitLane)
|
||||
sb.append('-');
|
||||
else
|
||||
sb.append(' ');
|
||||
}
|
||||
}
|
||||
|
||||
return sb.toString().stripTrailing();
|
||||
}
|
||||
|
||||
public CommitInfo getCommitInfo(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);
|
||||
DateTimeFormatter fmt = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm")
|
||||
.withZone(ZoneId.systemDefault());
|
||||
List<String> parents = Arrays.stream(commit.getParents())
|
||||
.map(p -> p.getId().abbreviate(7).name())
|
||||
.toList();
|
||||
return new CommitInfo(
|
||||
commit.getId().getName(),
|
||||
commit.getId().abbreviate(7).name(),
|
||||
commit.getFullMessage(),
|
||||
commit.getAuthorIdent().getName(),
|
||||
fmt.format(Instant.ofEpochSecond(commit.getCommitTime())),
|
||||
parents,
|
||||
""
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public List<DiffInfo> getCommitDiff(String name, String commitHash) throws IOException
|
||||
@@ -412,13 +520,15 @@ public class GitService
|
||||
if (dirPath != null && !dirPath.isEmpty())
|
||||
{
|
||||
tw.setFilter(PathFilter.create(dirPath));
|
||||
// Walk into the directory
|
||||
// Walk into the directory, entering intermediate subtrees as needed
|
||||
while (tw.next())
|
||||
{
|
||||
if (tw.isSubtree() && tw.getPathString().equals(dirPath))
|
||||
if (tw.isSubtree())
|
||||
{
|
||||
String path = tw.getPathString();
|
||||
tw.enterSubtree();
|
||||
break;
|
||||
if (path.equals(dirPath))
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -436,6 +546,14 @@ public class GitService
|
||||
}
|
||||
|
||||
public String getFileContentAtCommit(String name, String commitHash, String filePath) throws IOException
|
||||
{
|
||||
byte[] bytes = getRawFileAtCommit(name, commitHash, filePath);
|
||||
if (bytes == null)
|
||||
return null;
|
||||
return new String(bytes, StandardCharsets.UTF_8);
|
||||
}
|
||||
|
||||
public byte[] getRawFileAtCommit(String name, String commitHash, String filePath) throws IOException
|
||||
{
|
||||
try (Git git = openRepository(name))
|
||||
{
|
||||
@@ -451,7 +569,7 @@ public class GitService
|
||||
if (tw == null)
|
||||
return null;
|
||||
ObjectLoader loader = repo.open(tw.getObjectId(0));
|
||||
return new String(loader.getBytes(), StandardCharsets.UTF_8);
|
||||
return loader.getBytes();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
<p>
|
||||
<a th:href="@{/repo/{name}/commits(name=${name})}">< Back to commits</a>
|
||||
|
|
||||
<a th:href="@{/repo/{name}/tree/{hash}(name=${name}, hash=${hash})}">< Back to tree</a>
|
||||
<a th:href="@{/repo/{name}/tree/{hash}(name=${name}, hash=${hash}, path=${parentPath})}">< Parent directory</a>
|
||||
</p>
|
||||
|
||||
<table border="0" cellpadding="4" cellspacing="0">
|
||||
@@ -25,7 +25,10 @@
|
||||
</table>
|
||||
|
||||
<h3>Content</h3>
|
||||
<pre th:text="${content}">File content here</pre>
|
||||
<div th:if="${isImage}">
|
||||
<img th:src="@{/repo/{name}/raw/{hash}/{filePath}(name=${name}, hash=${hash}, filePath=${filePath})}" alt="" border="0">
|
||||
</div>
|
||||
<pre th:unless="${isImage}" th:text="${content}">File content here</pre>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -1,25 +1,27 @@
|
||||
<!DOCTYPE html>
|
||||
<html xmlns:th="http://www.thymeleaf.org">
|
||||
<head>
|
||||
<title th:text="'Changes - ' + ${name}">Changes</title>
|
||||
<title th:text="'Staging - ' + ${name}">Staging</title>
|
||||
</head>
|
||||
<body>
|
||||
<h2>Changes</h2>
|
||||
<h2>Staging</h2>
|
||||
<p>Branch: <b th:text="${branch}"></b></p>
|
||||
|
||||
<h3>Modified Files (unstaged)</h3>
|
||||
<form method="post" th:action="@{/repo/{name}/stage(name=${name})}" th:if="${!#lists.isEmpty(modifiedFiles)}">
|
||||
<table border="1" cellpadding="4" cellspacing="0">
|
||||
<tr>
|
||||
<th>Stage</th>
|
||||
<th></th>
|
||||
<th>File</th>
|
||||
</tr>
|
||||
<tr th:each="file : ${modifiedFiles}">
|
||||
<td><input type="checkbox" name="files" th:value="${file}"></td>
|
||||
<td th:text="${file}"></td>
|
||||
<td><a th:href="@{'/repo/' + ${name} + '/diff/' + ${file}}" th:text="${file}"></a></td>
|
||||
</tr>
|
||||
</table>
|
||||
<br>
|
||||
<input type="submit" value="Stage Selected">
|
||||
<input type="submit" name="action" value="Stage Selected">
|
||||
<input type="submit" name="action" value="Rollback Selected">
|
||||
</form>
|
||||
<p th:if="${#lists.isEmpty(modifiedFiles)}"><i>No modified files.</i></p>
|
||||
|
||||
@@ -27,12 +29,12 @@
|
||||
<form method="post" th:action="@{/repo/{name}/unstage(name=${name})}" th:if="${!#lists.isEmpty(stagedFiles)}">
|
||||
<table border="1" cellpadding="4" cellspacing="0">
|
||||
<tr>
|
||||
<th>Unstage</th>
|
||||
<th></th>
|
||||
<th>File</th>
|
||||
</tr>
|
||||
<tr th:each="file : ${stagedFiles}">
|
||||
<td><input type="checkbox" name="files" th:value="${file}"></td>
|
||||
<td th:text="${file}"></td>
|
||||
<td><a th:href="@{'/repo/' + ${name} + '/diff/' + ${file}}" th:text="${file}"></a></td>
|
||||
</tr>
|
||||
</table>
|
||||
<br>
|
||||
|
||||
@@ -8,6 +8,20 @@
|
||||
|
||||
<p><a th:href="@{/repo/{name}/commits(name=${name})}">< Back to commits</a></p>
|
||||
|
||||
<table border="0" cellpadding="4" cellspacing="0">
|
||||
<tr>
|
||||
<td><b>Author:</b></td>
|
||||
<td th:text="${commitInfo.author}"></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><b>Date:</b></td>
|
||||
<td th:text="${commitInfo.date}"></td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<h3 th:text="${commitTitle}"></h3>
|
||||
<pre th:if="${!commitBody.isEmpty()}" th:text="${commitBody}"></pre>
|
||||
|
||||
<table border="0" cellpadding="4" cellspacing="0">
|
||||
<tr>
|
||||
<td>
|
||||
|
||||
16
src/main/resources/templates/file-diff.html
Normal file
16
src/main/resources/templates/file-diff.html
Normal file
@@ -0,0 +1,16 @@
|
||||
<!DOCTYPE html>
|
||||
<html xmlns:th="http://www.thymeleaf.org">
|
||||
<head>
|
||||
<title th:text="'Diff - ' + ${filePath}">Diff</title>
|
||||
</head>
|
||||
<body>
|
||||
<h2 th:text="${filePath}">file.txt</h2>
|
||||
|
||||
<p><a th:href="@{/repo/{name}/changes(name=${name})}">< Back to staging</a></p>
|
||||
|
||||
<pre th:text="${diff}">Diff output here</pre>
|
||||
|
||||
<p th:if="${diff.isEmpty()}"><i>No differences found.</i></p>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
@@ -6,14 +6,15 @@
|
||||
<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>
|
||||
<a th:href="@{/repo/{name}/commits(name=${name})}">< Back to commits</a>
|
||||
<span th:if="${!path.isEmpty()}">
|
||||
| <a th:href="@{/repo/{name}/tree/{hash}(name=${name}, hash=${hash}, path=${parentPath})}">< Parent directory</a>
|
||||
</span>
|
||||
</p>
|
||||
|
||||
<p th:if="${!path.isEmpty()}">Path: <b th:text="${path}"></b></p>
|
||||
|
||||
<table border="1" cellpadding="4" cellspacing="0">
|
||||
<tr>
|
||||
<th>Type</th>
|
||||
|
||||
@@ -9,8 +9,10 @@ import org.springframework.test.web.servlet.MockMvc;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
|
||||
@@ -139,6 +141,18 @@ class RepoControllerTest
|
||||
verify(gitService).stageFiles("myrepo", List.of("a.txt", "b.txt"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void rollbackRedirectsToChanges() throws Exception
|
||||
{
|
||||
mockMvc.perform(post("/repo/myrepo/stage")
|
||||
.param("files", "a.txt")
|
||||
.param("action", "Rollback Selected"))
|
||||
.andExpect(status().is3xxRedirection())
|
||||
.andExpect(redirectedUrl("/repo/myrepo/changes"));
|
||||
|
||||
verify(gitService).rollbackFiles("myrepo", List.of("a.txt"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void unstageRedirectsToChanges() throws Exception
|
||||
{
|
||||
@@ -232,6 +246,9 @@ class RepoControllerTest
|
||||
@Test
|
||||
void commitDetailShowsDiff() throws Exception
|
||||
{
|
||||
when(gitService.getCommitInfo("myrepo", "abc1234")).thenReturn(
|
||||
new be.seeseepuff.webgit.model.CommitInfo("abc1234abc1234", "abc1234", "Fix bug\n\nDetails here", "Alice", "2024-01-01 12:00", List.of(), "")
|
||||
);
|
||||
when(gitService.getCommitDiff("myrepo", "abc1234")).thenReturn(List.of(
|
||||
new be.seeseepuff.webgit.model.DiffInfo("ADD", "/dev/null", "file.txt", "+hello")
|
||||
));
|
||||
@@ -240,6 +257,7 @@ class RepoControllerTest
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(view().name("commit"))
|
||||
.andExpect(model().attribute("hash", "abc1234"))
|
||||
.andExpect(content().string(org.hamcrest.Matchers.containsString("Fix bug")))
|
||||
.andExpect(content().string(org.hamcrest.Matchers.containsString("file.txt")));
|
||||
}
|
||||
|
||||
@@ -267,6 +285,37 @@ class RepoControllerTest
|
||||
.andExpect(content().string(org.hamcrest.Matchers.containsString("# Hello")));
|
||||
}
|
||||
|
||||
@Test
|
||||
void blobShowsImageTag() throws Exception
|
||||
{
|
||||
mockMvc.perform(get("/repo/myrepo/blob/abc1234/photo.png"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(view().name("blob"))
|
||||
.andExpect(content().string(org.hamcrest.Matchers.containsString("/repo/myrepo/raw/abc1234/photo.png")));
|
||||
verify(gitService, never()).getFileContentAtCommit(any(), any(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void rawFileServesBytes() throws Exception
|
||||
{
|
||||
byte[] imageBytes = new byte[]{(byte)0x89, 0x50, 0x4E, 0x47};
|
||||
when(gitService.getRawFileAtCommit("myrepo", "abc1234", "photo.png")).thenReturn(imageBytes);
|
||||
|
||||
mockMvc.perform(get("/repo/myrepo/raw/abc1234/photo.png"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(content().contentType("image/png"))
|
||||
.andExpect(content().bytes(imageBytes));
|
||||
}
|
||||
|
||||
@Test
|
||||
void rawFileReturns404WhenNotFound() throws Exception
|
||||
{
|
||||
when(gitService.getRawFileAtCommit("myrepo", "abc1234", "missing.png")).thenReturn(null);
|
||||
|
||||
mockMvc.perform(get("/repo/myrepo/raw/abc1234/missing.png"))
|
||||
.andExpect(status().isNotFound());
|
||||
}
|
||||
|
||||
@Test
|
||||
void checkoutCommitRedirectsToCommits() throws Exception
|
||||
{
|
||||
@@ -290,4 +339,16 @@ class RepoControllerTest
|
||||
|
||||
verify(gitService).checkoutFilesFromCommit("myrepo", "abc1234", List.of("a.txt", "b.txt"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void fileDiffShowsDiff() throws Exception
|
||||
{
|
||||
when(gitService.getWorkingTreeDiff("myrepo", "src/main.txt")).thenReturn("diff --git a/src/main.txt\n+hello");
|
||||
|
||||
mockMvc.perform(get("/repo/myrepo/diff/src/main.txt"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(view().name("file-diff"))
|
||||
.andExpect(model().attribute("filePath", "src/main.txt"))
|
||||
.andExpect(content().string(org.hamcrest.Matchers.containsString("+hello")));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -349,6 +349,66 @@ class GitServiceTest
|
||||
assertNotNull(commits.getFirst().graphLine());
|
||||
}
|
||||
|
||||
@Test
|
||||
void getCommitInfoReturnsDetails() throws GitAPIException, IOException
|
||||
{
|
||||
gitService.cloneRepository(bareRemote.toUri().toString(), "myrepo");
|
||||
|
||||
var commits = gitService.listCommits("myrepo");
|
||||
var info = gitService.getCommitInfo("myrepo", commits.getFirst().hash());
|
||||
assertNotNull(info);
|
||||
assertTrue(info.message().contains("Initial commit"));
|
||||
assertNotNull(info.author());
|
||||
assertNotNull(info.date());
|
||||
}
|
||||
|
||||
@Test
|
||||
void listCommitsGraphLineShowsBranchingCorrectly() throws GitAPIException, IOException
|
||||
{
|
||||
// Create repo with two branches from initial commit: main (C) and feature (B)
|
||||
gitService.cloneRepository(bareRemote.toUri().toString(), "myrepo");
|
||||
|
||||
// Make commit C on main
|
||||
Files.writeString(worktreePath.resolve("myrepo/main.txt"), "main");
|
||||
gitService.stageFiles("myrepo", List.of("main.txt"));
|
||||
gitService.commit("myrepo", "C");
|
||||
|
||||
// Create feature branch from initial commit (A) and make commit B
|
||||
gitService.createAndCheckoutBranch("myrepo", "feature");
|
||||
// Rewind feature to initial commit
|
||||
try (Git git = Git.open(worktreePath.resolve("myrepo").toFile()))
|
||||
{
|
||||
var initialCommit = gitService.listCommits("myrepo").stream()
|
||||
.filter(c -> c.message().equals("Initial commit"))
|
||||
.findFirst()
|
||||
.orElseThrow();
|
||||
git.reset().setMode(org.eclipse.jgit.api.ResetCommand.ResetType.HARD)
|
||||
.setRef(initialCommit.hash()).call();
|
||||
}
|
||||
Files.writeString(worktreePath.resolve("myrepo/feature.txt"), "feature");
|
||||
gitService.stageFiles("myrepo", List.of("feature.txt"));
|
||||
gitService.commit("myrepo", "B");
|
||||
|
||||
// Switch back to main
|
||||
gitService.checkoutBranch("myrepo", "master".equals(gitService.listBranches("myrepo").stream()
|
||||
.filter(b -> b.equals("master") || b.equals("main")).findFirst().orElse("master"))
|
||||
? "master" : "main");
|
||||
|
||||
var commits = gitService.listCommits("myrepo");
|
||||
|
||||
// Lane-0 commits should have graph lines starting with '*'
|
||||
var lane0Commits = commits.stream()
|
||||
.filter(c -> c.graphLine().startsWith("*"))
|
||||
.toList();
|
||||
// Lane-1 commit (B) should have '|' then '-' then '*'
|
||||
var lane1Commits = commits.stream()
|
||||
.filter(c -> c.graphLine().startsWith("|-"))
|
||||
.toList();
|
||||
|
||||
assertFalse(lane0Commits.isEmpty(), "Expected commits on lane 0");
|
||||
assertFalse(lane1Commits.isEmpty(), "Expected branching commit with |-* pattern");
|
||||
}
|
||||
|
||||
@Test
|
||||
void getCommitDiffReturnsDiffs() throws GitAPIException, IOException
|
||||
{
|
||||
@@ -373,6 +433,23 @@ class GitServiceTest
|
||||
assertTrue(files.stream().anyMatch(f -> f.path().equals("README.md")));
|
||||
}
|
||||
|
||||
@Test
|
||||
void listFilesAtCommitReturnsNestedDirectoryContents() throws GitAPIException, IOException
|
||||
{
|
||||
gitService.cloneRepository(bareRemote.toUri().toString(), "myrepo");
|
||||
// Create a file two levels deep: src/main/Hello.txt
|
||||
Path srcMain = worktreePath.resolve("myrepo/src/main");
|
||||
Files.createDirectories(srcMain);
|
||||
Files.writeString(srcMain.resolve("Hello.txt"), "hello");
|
||||
gitService.stageFiles("myrepo", List.of("src/main/Hello.txt"));
|
||||
gitService.commit("myrepo", "Add nested file");
|
||||
|
||||
var commits = gitService.listCommits("myrepo");
|
||||
var files = gitService.listFilesAtCommit("myrepo", commits.getFirst().hash(), "src/main");
|
||||
assertFalse(files.isEmpty());
|
||||
assertTrue(files.stream().anyMatch(f -> f.path().equals("src/main/Hello.txt")));
|
||||
}
|
||||
|
||||
@Test
|
||||
void getFileContentAtCommitReturnsContent() throws GitAPIException, IOException
|
||||
{
|
||||
@@ -383,6 +460,27 @@ class GitServiceTest
|
||||
assertEquals("# Test", content);
|
||||
}
|
||||
|
||||
@Test
|
||||
void getRawFileAtCommitReturnsBytes() throws GitAPIException, IOException
|
||||
{
|
||||
gitService.cloneRepository(bareRemote.toUri().toString(), "myrepo");
|
||||
|
||||
var commits = gitService.listCommits("myrepo");
|
||||
byte[] bytes = gitService.getRawFileAtCommit("myrepo", commits.getFirst().hash(), "README.md");
|
||||
assertNotNull(bytes);
|
||||
assertEquals("# Test", new String(bytes, java.nio.charset.StandardCharsets.UTF_8));
|
||||
}
|
||||
|
||||
@Test
|
||||
void getRawFileAtCommitReturnsNullForMissingFile() throws GitAPIException, IOException
|
||||
{
|
||||
gitService.cloneRepository(bareRemote.toUri().toString(), "myrepo");
|
||||
|
||||
var commits = gitService.listCommits("myrepo");
|
||||
byte[] bytes = gitService.getRawFileAtCommit("myrepo", commits.getFirst().hash(), "nonexistent.png");
|
||||
assertNull(bytes);
|
||||
}
|
||||
|
||||
@Test
|
||||
void getFileContentAtCommitReturnsNullForMissingFile() throws GitAPIException, IOException
|
||||
{
|
||||
@@ -419,4 +517,48 @@ class GitServiceTest
|
||||
|
||||
assertEquals("# Test", Files.readString(readme));
|
||||
}
|
||||
|
||||
@Test
|
||||
void getWorkingTreeDiffShowsUnstagedChanges() throws GitAPIException, IOException
|
||||
{
|
||||
gitService.cloneRepository(bareRemote.toUri().toString(), "myrepo");
|
||||
Files.writeString(worktreePath.resolve("myrepo").resolve("README.md"), "modified content");
|
||||
|
||||
String diff = gitService.getWorkingTreeDiff("myrepo", "README.md");
|
||||
assertFalse(diff.isEmpty());
|
||||
assertTrue(diff.contains("README.md"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void getWorkingTreeDiffShowsStagedChanges() throws GitAPIException, IOException
|
||||
{
|
||||
gitService.cloneRepository(bareRemote.toUri().toString(), "myrepo");
|
||||
Files.writeString(worktreePath.resolve("myrepo").resolve("README.md"), "staged content");
|
||||
gitService.stageFiles("myrepo", List.of("README.md"));
|
||||
|
||||
String diff = gitService.getWorkingTreeDiff("myrepo", "README.md");
|
||||
assertFalse(diff.isEmpty());
|
||||
assertTrue(diff.contains("README.md"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void getWorkingTreeDiffReturnsEmptyForUnchangedFile() throws GitAPIException, IOException
|
||||
{
|
||||
gitService.cloneRepository(bareRemote.toUri().toString(), "myrepo");
|
||||
|
||||
String diff = gitService.getWorkingTreeDiff("myrepo", "README.md");
|
||||
assertTrue(diff.isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void rollbackFilesRestoresContent() throws GitAPIException, IOException
|
||||
{
|
||||
gitService.cloneRepository(bareRemote.toUri().toString(), "myrepo");
|
||||
Path readme = worktreePath.resolve("myrepo").resolve("README.md");
|
||||
Files.writeString(readme, "modified");
|
||||
|
||||
gitService.rollbackFiles("myrepo", List.of("README.md"));
|
||||
|
||||
assertEquals("# Test", Files.readString(readme));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user