Compare commits

...

24 Commits

Author SHA1 Message Date
4077c1b28e Log test results
All checks were successful
Deploy / build (push) Successful in 1m41s
2026-02-27 22:02:13 +01:00
e6f6e2466b Add test step
All checks were successful
Deploy / build (push) Successful in 1m4s
2026-02-27 21:56:51 +01:00
5be1b1cc29 Throw exception on authentication issues 2026-02-27 21:54:30 +01:00
36ecd019a8 Add copilot instructions 2026-02-27 21:53:56 +01:00
472db2dd96 Incrase spacing in staging view
All checks were successful
Deploy / build (push) Successful in 50s
2026-02-27 19:31:10 +01:00
b5097685c7 Put push/pull buttons on one line
All checks were successful
Deploy / build (push) Successful in 49s
2026-02-27 19:29:08 +01:00
6a532322c4 Add credential settings
All checks were successful
Deploy / build (push) Successful in 55s
2026-02-27 19:22:38 +01:00
52fe455c76 Add ahead/behind indicator and push/pull buttons 2026-02-27 19:22:03 +01:00
a27c9fba00 Add watchtower step to workflow
All checks were successful
Deploy / build (push) Successful in 46s
2026-02-27 19:08:42 +01:00
9b1668def9 Add checkmark to mark all files
All checks were successful
Deploy / build (push) Successful in 46s
2026-02-27 19:06:00 +01:00
ec79a0c5cf Remove duplicate Push Container step in deploy workflow
All checks were successful
Deploy / build (push) Successful in 32s
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-02-27 11:35:11 +01:00
2dcacdbe8d Exclude master from build workflow trigger
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-02-27 11:34:39 +01:00
e079eed52d Use ubuntu-latest runner in CI/CD workflows
All checks were successful
Deploy / build (push) Successful in 1m22s
Build / build (push) Successful in 1m45s
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-02-27 11:33:19 +01:00
d803919bf5 Update deploy pipeline: run on master, tag as latest
Some checks failed
Build / build (push) Has been cancelled
Deploy / build (push) Has been cancelled
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-02-27 11:32:01 +01:00
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
17 changed files with 789 additions and 51 deletions

View File

@@ -0,0 +1,22 @@
name: Build
on:
push:
branches:
- '*'
- '!master'
jobs:
build:
runs-on: ubuntu-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,38 @@
name: Deploy
on:
push:
branches:
- master
jobs:
build:
runs-on: ubuntu-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: Run Tests
run: ./gradlew test
- name: Build Container
run: docker build --tag gitea.seeseepuff.be/seeseemelk/webgit:latest .
- 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:latest
- name: Trigger Watchtower
uses: https://gitea.seeseepuff.be/actions/watchtower@v1

26
.github/copilot-instructions.md vendored Normal file
View File

@@ -0,0 +1,26 @@
# Copilot Instructions
## System Requirements
This project targets **retro systems** (Windows 3.11, Windows 98, FreeDOS).
The UI must work in period-appropriate browsers:
- **No JavaScript** — all interactivity must be server-side.
- **No CSS** — use HTML attributes (`border`, `cellpadding`, `cellspacing`) for layout.
- **Use table-based layouts** to arrange elements side by side.
- Keep HTML simple: plain `<form>`, `<table>`, `<input>`, `<a>` elements only.
## Testing
- **All new functionality must have unit tests.**
- Service-layer tests go in `src/test/java/.../service/GitServiceTest.java` and use
real JGit repositories created in `@TempDir` directories.
- Controller tests go in `src/test/java/.../controller/RepoControllerTest.java` and
use `@WebMvcTest` with `@MockitoBean` for the service layer.
- Run `./gradlew test` to verify all tests pass before considering a change complete.
## Tech Stack
- Java 25, Spring Boot 4.0, Thymeleaf, Gradle
- JGit for all Git operations (no shelling out to `git`)
- Lombok for boilerplate reduction (`@Getter`, `@Setter`, `@RequiredArgsConstructor`)

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

@@ -52,6 +52,10 @@ environment variables / command-line arguments):
|---|---| |---|---|
| `webgit.worktree-path` | Path to the shared network drive where working trees are stored (the files your retro machines will access). | | `webgit.worktree-path` | Path to the shared network drive where working trees are stored (the files your retro machines will access). |
| `webgit.git-dir-path` | Path where `.git` directories are stored (can be a local disk on the server). | | `webgit.git-dir-path` | Path where `.git` directories are stored (can be a local disk on the server). |
| `webgit.username` | Username for push/pull authentication (e.g. a dedicated Gitea account). |
| `webgit.password` | Password or access token for push/pull authentication. |
Credentials can be supplied via environment variables (`WEBGIT_USERNAME`, `WEBGIT_PASSWORD`) to avoid storing them in config files.
Example: Example:

View File

@@ -43,6 +43,11 @@ dependencies {
tasks.withType<Test> { tasks.withType<Test> {
useJUnitPlatform() useJUnitPlatform()
finalizedBy(tasks.jacocoTestReport) finalizedBy(tasks.jacocoTestReport)
afterSuite(KotlinClosure2<TestDescriptor, TestResult, Unit>({ desc, result ->
if (desc.parent == null) {
println("Test results: ${result.testCount} tests, ${result.successfulTestCount} passed, ${result.failedTestCount} failed, ${result.skippedTestCount} skipped")
}
}))
} }
tasks.jacocoTestReport { tasks.jacocoTestReport {

View File

@@ -16,4 +16,6 @@ public class WebgitProperties
private Path worktreePath; private Path worktreePath;
private Path gitDirPath; private Path gitDirPath;
private Integer telnetPort; private Integer telnetPort;
private String username;
private String password;
} }

View File

@@ -3,6 +3,8 @@ package be.seeseepuff.webgit.controller;
import be.seeseepuff.webgit.service.GitService; import be.seeseepuff.webgit.service.GitService;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.eclipse.jgit.api.errors.GitAPIException; 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.stereotype.Controller;
import org.springframework.ui.Model; import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.GetMapping;
@@ -12,11 +14,25 @@ import org.springframework.web.bind.annotation.RequestParam;
import java.io.IOException; import java.io.IOException;
import java.util.List; import java.util.List;
import java.util.Map;
import java.util.Set;
@Controller @Controller
@RequiredArgsConstructor @RequiredArgsConstructor
public class RepoController 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; private final GitService gitService;
@GetMapping("/repo/{name}") @GetMapping("/repo/{name}")
@@ -38,11 +54,31 @@ public class RepoController
public String changes(@PathVariable String name, Model model) throws IOException, GitAPIException public String changes(@PathVariable String name, Model model) throws IOException, GitAPIException
{ {
model.addAttribute("name", name); model.addAttribute("name", name);
model.addAttribute("branch", gitService.getCurrentBranch(name));
model.addAttribute("modifiedFiles", gitService.getModifiedFiles(name)); model.addAttribute("modifiedFiles", gitService.getModifiedFiles(name));
model.addAttribute("stagedFiles", gitService.getStagedFiles(name)); model.addAttribute("stagedFiles", gitService.getStagedFiles(name));
int[] aheadBehind = gitService.getAheadBehind(name);
if (aheadBehind != null)
{
model.addAttribute("commitsAhead", aheadBehind[0]);
model.addAttribute("commitsBehind", aheadBehind[1]);
}
return "changes"; 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") @GetMapping("/repo/{name}/remote")
public String remote(@PathVariable String name, Model model) throws IOException public String remote(@PathVariable String name, Model model) throws IOException
{ {
@@ -73,6 +109,14 @@ public class RepoController
model.addAttribute("name", name); model.addAttribute("name", name);
model.addAttribute("hash", hash); model.addAttribute("hash", hash);
model.addAttribute("shortHash", hash.substring(0, Math.min(7, hash.length()))); 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)); model.addAttribute("diffs", gitService.getCommitDiff(name, hash));
return "commit"; return "commit";
} }
@@ -85,6 +129,7 @@ public class RepoController
model.addAttribute("hash", hash); model.addAttribute("hash", hash);
model.addAttribute("shortHash", hash.substring(0, Math.min(7, hash.length()))); model.addAttribute("shortHash", hash.substring(0, Math.min(7, hash.length())));
model.addAttribute("path", path); model.addAttribute("path", path);
model.addAttribute("parentPath", path.contains("/") ? path.substring(0, path.lastIndexOf('/')) : "");
model.addAttribute("files", gitService.listFilesAtCommit(name, hash, path)); model.addAttribute("files", gitService.listFilesAtCommit(name, hash, path));
return "tree"; return "tree";
} }
@@ -96,13 +141,34 @@ public class RepoController
String fullPath = request.getRequestURI(); String fullPath = request.getRequestURI();
String prefix = "/repo/" + name + "/blob/" + hash + "/"; String prefix = "/repo/" + name + "/blob/" + hash + "/";
String filePath = fullPath.substring(prefix.length()); 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("name", name);
model.addAttribute("hash", hash); model.addAttribute("hash", hash);
model.addAttribute("filePath", filePath); 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"; 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") @PostMapping("/repo/{name}/checkout-commit")
public String checkoutCommit(@PathVariable String name, @RequestParam String hash) throws IOException, GitAPIException public String checkoutCommit(@PathVariable String name, @RequestParam String hash) throws IOException, GitAPIException
{ {
@@ -133,16 +199,26 @@ public class RepoController
} }
@PostMapping("/repo/{name}/stage") @PostMapping("/repo/{name}/stage")
public String stage(@PathVariable String name, @RequestParam List<String> files) throws IOException, GitAPIException public String stage(@PathVariable String name,
@RequestParam(required = false) List<String> files,
@RequestParam(required = false) String selectAll,
@RequestParam(defaultValue = "Stage Selected") String action) throws IOException, GitAPIException
{ {
gitService.stageFiles(name, files); List<String> filesToProcess = (selectAll != null) ? gitService.getModifiedFiles(name) : (files != null ? files : List.of());
if ("Rollback Selected".equals(action))
gitService.rollbackFiles(name, filesToProcess);
else
gitService.stageFiles(name, filesToProcess);
return "redirect:/repo/" + name + "/changes"; return "redirect:/repo/" + name + "/changes";
} }
@PostMapping("/repo/{name}/unstage") @PostMapping("/repo/{name}/unstage")
public String unstage(@PathVariable String name, @RequestParam List<String> files) throws IOException, GitAPIException public String unstage(@PathVariable String name,
@RequestParam(required = false) List<String> files,
@RequestParam(required = false) String selectAll) throws IOException, GitAPIException
{ {
gitService.unstageFiles(name, files); List<String> filesToProcess = (selectAll != null) ? gitService.getStagedFiles(name) : (files != null ? files : List.of());
gitService.unstageFiles(name, filesToProcess);
return "redirect:/repo/" + name + "/changes"; return "redirect:/repo/" + name + "/changes";
} }
@@ -154,17 +230,19 @@ public class RepoController
} }
@PostMapping("/repo/{name}/push") @PostMapping("/repo/{name}/push")
public String push(@PathVariable String name) throws IOException, GitAPIException public String push(@PathVariable String name,
@RequestParam(required = false, defaultValue = "remote") String redirectTo) throws IOException, GitAPIException
{ {
gitService.push(name); gitService.push(name);
return "redirect:/repo/" + name + "/remote"; return "redirect:/repo/" + name + "/" + redirectTo;
} }
@PostMapping("/repo/{name}/pull") @PostMapping("/repo/{name}/pull")
public String pull(@PathVariable String name) throws IOException, GitAPIException public String pull(@PathVariable String name,
@RequestParam(required = false, defaultValue = "remote") String redirectTo) throws IOException, GitAPIException
{ {
gitService.pull(name); gitService.pull(name);
return "redirect:/repo/" + name + "/remote"; return "redirect:/repo/" + name + "/" + redirectTo;
} }
@PostMapping("/repo/{name}/update-remote") @PostMapping("/repo/{name}/update-remote")

View File

@@ -36,8 +36,10 @@ import java.time.format.DateTimeFormatter;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Arrays; import java.util.Arrays;
import java.util.LinkedHashMap; import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Set;
import java.util.stream.Stream; import java.util.stream.Stream;
@Service @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 public void stageFiles(String name, List<String> files) throws IOException, GitAPIException
{ {
try (Git git = openRepository(name)) 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 public void commit(String name, String message) throws IOException, GitAPIException
{ {
try (Git git = openRepository(name)) try (Git git = openRepository(name))
@@ -297,17 +351,25 @@ public class GitService
plotCommitList.source(plotWalk); plotCommitList.source(plotWalk);
plotCommitList.fillTo(Integer.MAX_VALUE); plotCommitList.fillTo(Integer.MAX_VALUE);
int maxLanes = 0; // Track which lane positions are currently "open" (started but parent not yet seen)
for (PlotCommit<PlotLane> pc : plotCommitList) Set<Integer> openLanes = new LinkedHashSet<>();
{
if (pc.getLane() != null && pc.getLane().getPosition() + 1 > maxLanes)
maxLanes = pc.getLane().getPosition() + 1;
}
List<CommitInfo> commits = new ArrayList<>(); List<CommitInfo> commits = new ArrayList<>();
for (PlotCommit<PlotLane> pc : plotCommitList) 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()) List<String> parents = Arrays.stream(pc.getParents())
.map(p -> p.getId().abbreviate(7).name()) .map(p -> p.getId().abbreviate(7).name())
.toList(); .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 commitLane = 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 // Find the leftmost parent lane that is to the left of this commit's lane.
for (int i = 0; i < pc.getChildCount(); i++) // 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 (parent instanceof PlotCommit<?> pp && pp.getLane() != null)
if (child.getLane() != null)
{ {
int childPos = child.getLane().getPosition(); int pLane = pp.getLane().getPosition();
if (childPos < width) if (pLane < commitLane)
line[childPos] = '|'; mergeFromLane = Math.min(mergeFromLane, pLane);
} }
} }
line[lanePos] = '*'; int width = activeLanes.stream().mapToInt(Integer::intValue).max().orElse(0) + 1;
return new String(line);
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 public List<DiffInfo> getCommitDiff(String name, String commitHash) throws IOException
@@ -412,13 +520,15 @@ public class GitService
if (dirPath != null && !dirPath.isEmpty()) if (dirPath != null && !dirPath.isEmpty())
{ {
tw.setFilter(PathFilter.create(dirPath)); tw.setFilter(PathFilter.create(dirPath));
// Walk into the directory // Walk into the directory, entering intermediate subtrees as needed
while (tw.next()) while (tw.next())
{ {
if (tw.isSubtree() && tw.getPathString().equals(dirPath)) if (tw.isSubtree())
{ {
String path = tw.getPathString();
tw.enterSubtree(); 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 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)) try (Git git = openRepository(name))
{ {
@@ -451,7 +569,7 @@ public class GitService
if (tw == null) if (tw == null)
return null; return null;
ObjectLoader loader = repo.open(tw.getObjectId(0)); ObjectLoader loader = repo.open(tw.getObjectId(0));
return new String(loader.getBytes(), StandardCharsets.UTF_8); return loader.getBytes();
} }
} }
} }
@@ -480,11 +598,41 @@ public class GitService
} }
} }
public int[] getAheadBehind(String name) throws IOException
{
try (Git git = openRepository(name))
{
String branch = git.getRepository().getBranch();
org.eclipse.jgit.lib.BranchTrackingStatus status =
org.eclipse.jgit.lib.BranchTrackingStatus.of(git.getRepository(), branch);
if (status == null)
return null;
return new int[]{status.getAheadCount(), status.getBehindCount()};
}
}
public void push(String name) throws IOException, GitAPIException public void push(String name) throws IOException, GitAPIException
{ {
try (Git git = openRepository(name)) try (Git git = openRepository(name))
{ {
git.push().call(); var cmd = git.push();
if (properties.getUsername() != null)
cmd.setCredentialsProvider(new org.eclipse.jgit.transport.UsernamePasswordCredentialsProvider(
properties.getUsername(), properties.getPassword()));
Iterable<org.eclipse.jgit.transport.PushResult> results = cmd.call();
for (var result : results)
{
for (var update : result.getRemoteUpdates())
{
var status = update.getStatus();
if (status != org.eclipse.jgit.transport.RemoteRefUpdate.Status.OK
&& status != org.eclipse.jgit.transport.RemoteRefUpdate.Status.UP_TO_DATE)
{
throw new IOException("Push failed: " + update.getRemoteName() + " " + status
+ (update.getMessage() != null ? " - " + update.getMessage() : ""));
}
}
}
} }
} }
@@ -492,7 +640,15 @@ public class GitService
{ {
try (Git git = openRepository(name)) try (Git git = openRepository(name))
{ {
git.pull().call(); var cmd = git.pull();
if (properties.getUsername() != null)
cmd.setCredentialsProvider(new org.eclipse.jgit.transport.UsernamePasswordCredentialsProvider(
properties.getUsername(), properties.getPassword()));
var result = cmd.call();
if (!result.isSuccessful())
{
throw new IOException("Pull failed: " + result.getMergeResult().getMergeStatus());
}
} }
} }

View File

@@ -4,3 +4,7 @@ webgit.worktree-path=./webgit/worktree
webgit.telnet.enabled=true webgit.telnet.enabled=true
webgit.telnet-port=2323 webgit.telnet-port=2323
# Optional: credentials for push/pull (can also be set via WEBGIT_USERNAME / WEBGIT_PASSWORD env vars)
#webgit.username=
#webgit.password=

View File

@@ -9,7 +9,7 @@
<p> <p>
<a th:href="@{/repo/{name}/commits(name=${name})}">&lt; Back to commits</a> <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> </p>
<table border="0" cellpadding="4" cellspacing="0"> <table border="0" cellpadding="4" cellspacing="0">
@@ -25,7 +25,10 @@
</table> </table>
<h3>Content</h3> <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> </body>
</html> </html>

View File

@@ -1,25 +1,44 @@
<!DOCTYPE html> <!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org"> <html xmlns:th="http://www.thymeleaf.org">
<head> <head>
<title th:text="'Changes - ' + ${name}">Changes</title> <title th:text="'Staging - ' + ${name}">Staging</title>
</head> </head>
<body> <body>
<h2>Changes</h2> <h2>Staging</h2>
<p>Branch: <b th:text="${branch}"></b></p>
<table th:if="${commitsAhead != null}" border="0" cellpadding="4" cellspacing="0">
<tr>
<td><span th:text="${commitsAhead}"></span> ahead, <span th:text="${commitsBehind}"></span> behind</td>
<td>
<form method="post" th:action="@{/repo/{name}/push(name=${name})}">
<input type="hidden" name="redirectTo" value="changes">
<input type="submit" value="Push">
</form>
</td>
<td>
<form method="post" th:action="@{/repo/{name}/pull(name=${name})}">
<input type="hidden" name="redirectTo" value="changes">
<input type="submit" value="Pull">
</form>
</td>
</tr>
</table>
<h3>Modified Files (unstaged)</h3> <h3>Modified Files (unstaged)</h3>
<form method="post" th:action="@{/repo/{name}/stage(name=${name})}" th:if="${!#lists.isEmpty(modifiedFiles)}"> <form method="post" th:action="@{/repo/{name}/stage(name=${name})}" th:if="${!#lists.isEmpty(modifiedFiles)}">
<table border="1" cellpadding="4" cellspacing="0"> <table border="1" cellpadding="4" cellspacing="0">
<tr> <tr>
<th>Stage</th> <th><input type="checkbox" name="selectAll"></th>
<th>File</th> <th>File</th>
</tr> </tr>
<tr th:each="file : ${modifiedFiles}"> <tr th:each="file : ${modifiedFiles}">
<td><input type="checkbox" name="files" th:value="${file}"></td> <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> </tr>
</table> </table>
<br> <br>
<input type="submit" value="Stage Selected"> <input type="submit" name="action" value="Stage Selected">
<input type="submit" name="action" value="Rollback Selected">
</form> </form>
<p th:if="${#lists.isEmpty(modifiedFiles)}"><i>No modified files.</i></p> <p th:if="${#lists.isEmpty(modifiedFiles)}"><i>No modified files.</i></p>
@@ -27,12 +46,12 @@
<form method="post" th:action="@{/repo/{name}/unstage(name=${name})}" th:if="${!#lists.isEmpty(stagedFiles)}"> <form method="post" th:action="@{/repo/{name}/unstage(name=${name})}" th:if="${!#lists.isEmpty(stagedFiles)}">
<table border="1" cellpadding="4" cellspacing="0"> <table border="1" cellpadding="4" cellspacing="0">
<tr> <tr>
<th>Unstage</th> <th><input type="checkbox" name="selectAll"></th>
<th>File</th> <th>File</th>
</tr> </tr>
<tr th:each="file : ${stagedFiles}"> <tr th:each="file : ${stagedFiles}">
<td><input type="checkbox" name="files" th:value="${file}"></td> <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> </tr>
</table> </table>
<br> <br>

View File

@@ -8,6 +8,20 @@
<p><a th:href="@{/repo/{name}/commits(name=${name})}">&lt; Back to commits</a></p> <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"> <table border="0" cellpadding="4" cellspacing="0">
<tr> <tr>
<td> <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> <body>
<h2>Browse <span th:text="${shortHash}"></span></h2> <h2>Browse <span th:text="${shortHash}"></span></h2>
<p><a th:href="@{/repo/{name}/commits(name=${name})}">&lt; Back to commits</a></p> <p>
<a th:href="@{/repo/{name}/commits(name=${name})}">&lt; Back to commits</a>
<p th:if="${!path.isEmpty()}"> <span th:if="${!path.isEmpty()}">
Path: <b th:text="${path}"></b> | <a th:href="@{/repo/{name}/tree/{hash}(name=${name}, hash=${hash}, path=${parentPath})}">&lt; Parent directory</a>
<br> </span>
<a th:href="@{/repo/{name}/tree/{hash}(name=${name}, hash=${hash}, path=${path.contains('/') ? path.substring(0, path.lastIndexOf('/')) : ''})}">&lt; Parent directory</a>
</p> </p>
<p th:if="${!path.isEmpty()}">Path: <b th:text="${path}"></b></p>
<table border="1" cellpadding="4" cellspacing="0"> <table border="1" cellpadding="4" cellspacing="0">
<tr> <tr>
<th>Type</th> <th>Type</th>

View File

@@ -9,8 +9,10 @@ import org.springframework.test.web.servlet.MockMvc;
import java.util.List; import java.util.List;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when; 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.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; 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")); 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 @Test
void unstageRedirectsToChanges() throws Exception void unstageRedirectsToChanges() throws Exception
{ {
@@ -232,6 +246,9 @@ class RepoControllerTest
@Test @Test
void commitDetailShowsDiff() throws Exception 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( when(gitService.getCommitDiff("myrepo", "abc1234")).thenReturn(List.of(
new be.seeseepuff.webgit.model.DiffInfo("ADD", "/dev/null", "file.txt", "+hello") new be.seeseepuff.webgit.model.DiffInfo("ADD", "/dev/null", "file.txt", "+hello")
)); ));
@@ -240,6 +257,7 @@ class RepoControllerTest
.andExpect(status().isOk()) .andExpect(status().isOk())
.andExpect(view().name("commit")) .andExpect(view().name("commit"))
.andExpect(model().attribute("hash", "abc1234")) .andExpect(model().attribute("hash", "abc1234"))
.andExpect(content().string(org.hamcrest.Matchers.containsString("Fix bug")))
.andExpect(content().string(org.hamcrest.Matchers.containsString("file.txt"))); .andExpect(content().string(org.hamcrest.Matchers.containsString("file.txt")));
} }
@@ -267,6 +285,37 @@ class RepoControllerTest
.andExpect(content().string(org.hamcrest.Matchers.containsString("# Hello"))); .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 @Test
void checkoutCommitRedirectsToCommits() throws Exception void checkoutCommitRedirectsToCommits() throws Exception
{ {
@@ -290,4 +339,90 @@ class RepoControllerTest
verify(gitService).checkoutFilesFromCommit("myrepo", "abc1234", List.of("a.txt", "b.txt")); 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")));
}
@Test
void changesPageShowsAheadBehind() throws Exception
{
when(gitService.getModifiedFiles("myrepo")).thenReturn(List.of());
when(gitService.getStagedFiles("myrepo")).thenReturn(List.of());
when(gitService.getAheadBehind("myrepo")).thenReturn(new int[]{3, 1});
mockMvc.perform(get("/repo/myrepo/changes"))
.andExpect(status().isOk())
.andExpect(model().attribute("commitsAhead", 3))
.andExpect(model().attribute("commitsBehind", 1));
}
@Test
void changesPageHidesAheadBehindWhenNoTracking() throws Exception
{
when(gitService.getModifiedFiles("myrepo")).thenReturn(List.of());
when(gitService.getStagedFiles("myrepo")).thenReturn(List.of());
when(gitService.getAheadBehind("myrepo")).thenReturn(null);
mockMvc.perform(get("/repo/myrepo/changes"))
.andExpect(status().isOk())
.andExpect(model().attributeDoesNotExist("commitsAhead"))
.andExpect(model().attributeDoesNotExist("commitsBehind"));
}
@Test
void pushRedirectsToChangesWhenRequested() throws Exception
{
mockMvc.perform(post("/repo/myrepo/push")
.param("redirectTo", "changes"))
.andExpect(status().is3xxRedirection())
.andExpect(redirectedUrl("/repo/myrepo/changes"));
verify(gitService).push("myrepo");
}
@Test
void pullRedirectsToChangesWhenRequested() throws Exception
{
mockMvc.perform(post("/repo/myrepo/pull")
.param("redirectTo", "changes"))
.andExpect(status().is3xxRedirection())
.andExpect(redirectedUrl("/repo/myrepo/changes"));
verify(gitService).pull("myrepo");
}
@Test
void stageWithSelectAllStagesAllModifiedFiles() throws Exception
{
when(gitService.getModifiedFiles("myrepo")).thenReturn(List.of("a.txt", "b.txt", "c.txt"));
mockMvc.perform(post("/repo/myrepo/stage")
.param("selectAll", "on"))
.andExpect(status().is3xxRedirection())
.andExpect(redirectedUrl("/repo/myrepo/changes"));
verify(gitService).stageFiles("myrepo", List.of("a.txt", "b.txt", "c.txt"));
}
@Test
void unstageWithSelectAllUnstagesAllStagedFiles() throws Exception
{
when(gitService.getStagedFiles("myrepo")).thenReturn(List.of("a.txt", "b.txt"));
mockMvc.perform(post("/repo/myrepo/unstage")
.param("selectAll", "on"))
.andExpect(status().is3xxRedirection())
.andExpect(redirectedUrl("/repo/myrepo/changes"));
verify(gitService).unstageFiles("myrepo", List.of("a.txt", "b.txt"));
}
} }

View File

@@ -349,6 +349,66 @@ class GitServiceTest
assertNotNull(commits.getFirst().graphLine()); 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 @Test
void getCommitDiffReturnsDiffs() throws GitAPIException, IOException void getCommitDiffReturnsDiffs() throws GitAPIException, IOException
{ {
@@ -373,6 +433,23 @@ class GitServiceTest
assertTrue(files.stream().anyMatch(f -> f.path().equals("README.md"))); 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 @Test
void getFileContentAtCommitReturnsContent() throws GitAPIException, IOException void getFileContentAtCommitReturnsContent() throws GitAPIException, IOException
{ {
@@ -383,6 +460,27 @@ class GitServiceTest
assertEquals("# Test", content); 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 @Test
void getFileContentAtCommitReturnsNullForMissingFile() throws GitAPIException, IOException void getFileContentAtCommitReturnsNullForMissingFile() throws GitAPIException, IOException
{ {
@@ -419,4 +517,116 @@ class GitServiceTest
assertEquals("# Test", Files.readString(readme)); 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));
}
@Test
void getAheadBehindReturnsZeroWhenUpToDate() throws GitAPIException, IOException
{
gitService.cloneRepository(bareRemote.toUri().toString(), "myrepo");
int[] result = gitService.getAheadBehind("myrepo");
assertNotNull(result);
assertEquals(0, result[0]);
assertEquals(0, result[1]);
}
@Test
void getAheadBehindReturnsAheadCount() throws GitAPIException, IOException
{
gitService.cloneRepository(bareRemote.toUri().toString(), "myrepo");
Files.writeString(worktreePath.resolve("myrepo/newfile.txt"), "hello");
gitService.stageFiles("myrepo", List.of("newfile.txt"));
gitService.commit("myrepo", "Local commit");
int[] result = gitService.getAheadBehind("myrepo");
assertNotNull(result);
assertEquals(1, result[0]);
assertEquals(0, result[1]);
}
@Test
void getAheadBehindReturnsBehindCount() throws GitAPIException, IOException
{
gitService.cloneRepository(bareRemote.toUri().toString(), "myrepo");
// Push a commit to the remote via a second clone
Path tmpWork = tempDir.resolve("tmp-work2");
try (Git tmp = Git.cloneRepository()
.setURI(bareRemote.toUri().toString())
.setDirectory(tmpWork.toFile())
.call())
{
Files.writeString(tmpWork.resolve("remote-file.txt"), "remote");
tmp.add().addFilepattern("remote-file.txt").call();
tmp.commit().setMessage("Remote commit").call();
tmp.push().call();
}
// Fetch so our repo knows about the remote commit
try (Git git = Git.open(worktreePath.resolve("myrepo").toFile()))
{
git.fetch().call();
}
int[] result = gitService.getAheadBehind("myrepo");
assertNotNull(result);
assertEquals(0, result[0]);
assertEquals(1, result[1]);
}
@Test
void pushFailsWithBadRemote() throws GitAPIException, IOException
{
gitService.cloneRepository(bareRemote.toUri().toString(), "myrepo");
gitService.updateRemoteUrl("myrepo", "origin", "https://invalid.example.com/repo.git");
Files.writeString(worktreePath.resolve("myrepo/newfile.txt"), "hello");
gitService.stageFiles("myrepo", List.of("newfile.txt"));
gitService.commit("myrepo", "Local commit");
assertThrows(Exception.class, () -> gitService.push("myrepo"));
}
} }