Compare commits

...

10 Commits

Author SHA1 Message Date
98de13b410 Add Dockerfile and Gitea CI/CD workflows
Some checks failed
Build / build (push) Has been cancelled
- Dockerfile: eclipse-temurin:25-alpine, exposes port 8080
- build.yml: builds on every branch push with Java 25
- deploy.yml: builds and pushes Docker image on v* tags

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-02-27 11:29:40 +01:00
8784dfc391 Show commit title, description, author and date on commit detail page
Added GitService.getCommitInfo() using getFullMessage() to preserve
the full commit body. Controller splits the message into title (first
line) and body (remainder) for separate display in the template.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-02-27 11:24:40 +01:00
b0016767e8 Show current branch name in staging view
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-02-27 11:12:13 +01:00
04a69c323e Fix commit graph rendering for branching history
Track active lanes via explicit state (openLanes set) rather than
relying on PlotCommit.getPassingLanes() which is not available. Draw
'-' between passing lanes and a commit when the commit's parent is
on a lane to the left (branch convergence), giving output like:

  *     <- C (lane 0)
  | *   <- D (lane 0 passing, D on lane 1, no convergence)
  * |   <- C (lane 0, lane 1 passing)
  |-*   <- B (lane 1, converges to A on lane 0)
  *     <- A (lane 0)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-02-27 10:48:34 +01:00
eb222716cd Move parent directory link inline with back to commits
Both tree.html and blob.html now show '< Parent directory' on the
same line as '< Back to commits', separated by '|'. In blob view,
parent directory navigates to the containing directory tree.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-02-27 10:20:07 +01:00
d68933bc2f Render images in file browser blob view
Add /repo/{name}/raw/{hash}/** endpoint serving binary file content
with correct Content-Type. Blob view detects image extensions (png,
jpg, gif, bmp, webp, svg, ico) and renders an <img> tag instead of
a <pre> text block.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-02-27 10:17:30 +01:00
b0c869829f Fix file browser for directories two levels deep
TreeWalk was only entering exact-match subtrees, missing intermediate
directories. Now enters all subtrees that are prefix of the target path.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-02-27 10:13:15 +01:00
321e268530 Add Rollback Selected button to staging page
Rollback restores selected unstaged files to their HEAD state
using git checkout. The button shares the form with Stage Selected,
distinguished by the submit button's name/value pair.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-02-27 10:05:41 +01:00
1b6f007eea Remove Stage/Unstage column headers from staging tables
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-02-27 10:02:20 +01:00
b2b3993d85 Add file diff view to staging page, fix title
Rename 'Changes' to 'Staging' in the page title and heading.
File names in both modified and staged file lists are now
clickable links that show the diff for that file. The diff
view shows both unstaged (index vs working tree) and staged
(HEAD vs index) differences.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-02-27 09:57:53 +01:00
12 changed files with 522 additions and 43 deletions

View 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

View 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
View 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

View File

@@ -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";
}

View File

@@ -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();
}
}
}

View File

@@ -9,7 +9,7 @@
<p>
<a th:href="@{/repo/{name}/commits(name=${name})}">&lt; Back to commits</a>
|
<a th:href="@{/repo/{name}/tree/{hash}(name=${name}, hash=${hash})}">&lt; Back to tree</a>
<a th:href="@{/repo/{name}/tree/{hash}(name=${name}, hash=${hash}, path=${parentPath})}">&lt; 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>

View File

@@ -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>

View File

@@ -8,6 +8,20 @@
<p><a th:href="@{/repo/{name}/commits(name=${name})}">&lt; 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>

View 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})}">&lt; 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>

View File

@@ -6,14 +6,15 @@
<body>
<h2>Browse <span th:text="${shortHash}"></span></h2>
<p><a th:href="@{/repo/{name}/commits(name=${name})}">&lt; 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('/')) : ''})}">&lt; Parent directory</a>
<p>
<a th:href="@{/repo/{name}/commits(name=${name})}">&lt; Back to commits</a>
<span th:if="${!path.isEmpty()}">
| <a th:href="@{/repo/{name}/tree/{hash}(name=${name}, hash=${hash}, path=${parentPath})}">&lt; 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>

View File

@@ -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")));
}
}

View File

@@ -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));
}
}