Compare commits

..

30 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
fbfffac73f Fix branch/commit view bugs
- Filter out non-branch refs (like HEAD) from branch list
- Walk all branch tips in commit list so older checkouts still
  show newer commits
- Show '(current)' instead of Checkout button for HEAD commit
- Swap nav link order in blob view (commits first, then tree)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-02-27 09:52:59 +01:00
fdc520cfaf Add commits page with graph, diff, file browser, and checkout
New features:
- Commits list with ASCII graph, hash, message, author, date
- Single commit diff view with per-file diffs
- File tree browser at any commit
- File content viewer at any commit
- Checkout entire commit (detached HEAD)
- Checkout selected files from a commit

New GitService methods: listCommits, getCommitDiff,
listFilesAtCommit, getFileContentAtCommit, checkoutCommit,
checkoutFilesFromCommit.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-02-27 09:44:38 +01:00
383864469d Add delete confirmation page before removing repository
The Delete Repository button now navigates to a confirmation page
asking 'Are you sure?' with Yes/No options. Only the Yes button
performs the actual delete POST. No JavaScript required.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-02-27 09:31:03 +01:00
4458eb204b Show remotes in table with editable URLs on remote page
List all configured remotes in a table with name, editable URL
field with Save button, and per-remote Push/Pull buttons.
Add GitService.listRemotes() and updateRemoteUrl() methods.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-02-27 09:28:43 +01:00
005e0c7d23 Make staging (changes) the default repo view
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-02-27 09:23:59 +01:00
be130582fc Reorder sidebar: Staging above Branches, rename Changes to Staging
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-02-27 09:21:34 +01:00
26 changed files with 1556 additions and 28 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,17 +14,31 @@ 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}")
public String repo(@PathVariable String name) public String repo(@PathVariable String name)
{ {
return "redirect:/repo/" + name + "/branches"; return "redirect:/repo/" + name + "/changes";
} }
@GetMapping("/repo/{name}/branches") @GetMapping("/repo/{name}/branches")
@@ -38,15 +54,36 @@ 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) public String remote(@PathVariable String name, Model model) throws IOException
{ {
model.addAttribute("name", name); model.addAttribute("name", name);
model.addAttribute("remotes", gitService.listRemotes(name));
return "remote"; return "remote";
} }
@@ -57,6 +94,96 @@ public class RepoController
return "manage"; return "manage";
} }
@GetMapping("/repo/{name}/commits")
public String commits(@PathVariable String name, Model model) throws IOException
{
model.addAttribute("name", name);
model.addAttribute("commits", gitService.listCommits(name));
model.addAttribute("headHash", gitService.getHeadCommitHash(name));
return "commits";
}
@GetMapping("/repo/{name}/commit/{hash}")
public String commitDetail(@PathVariable String name, @PathVariable String hash, Model model) throws IOException
{
model.addAttribute("name", name);
model.addAttribute("hash", hash);
model.addAttribute("shortHash", hash.substring(0, Math.min(7, hash.length())));
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";
}
@GetMapping("/repo/{name}/tree/{hash}")
public String tree(@PathVariable String name, @PathVariable String hash,
@RequestParam(required = false, defaultValue = "") String path, Model model) throws IOException
{
model.addAttribute("name", name);
model.addAttribute("hash", hash);
model.addAttribute("shortHash", hash.substring(0, Math.min(7, hash.length())));
model.addAttribute("path", path);
model.addAttribute("parentPath", path.contains("/") ? path.substring(0, path.lastIndexOf('/')) : "");
model.addAttribute("files", gitService.listFilesAtCommit(name, hash, path));
return "tree";
}
@GetMapping("/repo/{name}/blob/{hash}/**")
public String blob(@PathVariable String name, @PathVariable String hash,
jakarta.servlet.http.HttpServletRequest request, Model model) throws IOException
{
String fullPath = request.getRequestURI();
String prefix = "/repo/" + name + "/blob/" + hash + "/";
String filePath = fullPath.substring(prefix.length());
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("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
{
gitService.checkoutCommit(name, hash);
return "redirect:/repo/" + name + "/commits";
}
@PostMapping("/repo/{name}/checkout-files")
public String checkoutFiles(@PathVariable String name, @RequestParam String hash,
@RequestParam List<String> files) throws IOException, GitAPIException
{
gitService.checkoutFilesFromCommit(name, hash, files);
return "redirect:/repo/" + name + "/changes";
}
@PostMapping("/repo/{name}/checkout") @PostMapping("/repo/{name}/checkout")
public String checkout(@PathVariable String name, @RequestParam String branch) throws IOException, GitAPIException public String checkout(@PathVariable String name, @RequestParam String branch) throws IOException, GitAPIException
{ {
@@ -72,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";
} }
@@ -93,19 +230,35 @@ 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 + "/" + redirectTo;
}
@PostMapping("/repo/{name}/update-remote")
public String updateRemote(@PathVariable String name, @RequestParam String remote, @RequestParam String url) throws IOException
{
gitService.updateRemoteUrl(name, remote, url);
return "redirect:/repo/" + name + "/remote"; return "redirect:/repo/" + name + "/remote";
} }
@GetMapping("/repo/{name}/confirm-delete")
public String confirmDelete(@PathVariable String name, Model model)
{
model.addAttribute("name", name);
return "confirm-delete";
}
@PostMapping("/repo/{name}/delete") @PostMapping("/repo/{name}/delete")
public String delete(@PathVariable String name) throws IOException public String delete(@PathVariable String name) throws IOException
{ {

View File

@@ -0,0 +1,15 @@
package be.seeseepuff.webgit.model;
import java.util.List;
public record CommitInfo(
String hash,
String shortHash,
String message,
String author,
String date,
List<String> parentHashes,
String graphLine
)
{
}

View File

@@ -0,0 +1,10 @@
package be.seeseepuff.webgit.model;
public record DiffInfo(
String changeType,
String oldPath,
String newPath,
String diff
)
{
}

View File

@@ -0,0 +1,8 @@
package be.seeseepuff.webgit.model;
public record FileInfo(
String path,
String type
)
{
}

View File

@@ -1,19 +1,45 @@
package be.seeseepuff.webgit.service; package be.seeseepuff.webgit.service;
import be.seeseepuff.webgit.config.WebgitProperties; import be.seeseepuff.webgit.config.WebgitProperties;
import be.seeseepuff.webgit.model.CommitInfo;
import be.seeseepuff.webgit.model.DiffInfo;
import be.seeseepuff.webgit.model.FileInfo;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.eclipse.jgit.api.Git; import org.eclipse.jgit.api.Git;
import org.eclipse.jgit.api.errors.GitAPIException; import org.eclipse.jgit.api.errors.GitAPIException;
import org.eclipse.jgit.diff.DiffEntry;
import org.eclipse.jgit.diff.DiffFormatter;
import org.eclipse.jgit.lib.ObjectId;
import org.eclipse.jgit.lib.ObjectLoader;
import org.eclipse.jgit.lib.Ref; import org.eclipse.jgit.lib.Ref;
import org.eclipse.jgit.lib.Repository; import org.eclipse.jgit.lib.Repository;
import org.eclipse.jgit.lib.StoredConfig; import org.eclipse.jgit.lib.StoredConfig;
import org.eclipse.jgit.revplot.PlotCommit;
import org.eclipse.jgit.revplot.PlotCommitList;
import org.eclipse.jgit.revplot.PlotLane;
import org.eclipse.jgit.revplot.PlotWalk;
import org.eclipse.jgit.revwalk.RevCommit;
import org.eclipse.jgit.revwalk.RevTree;
import org.eclipse.jgit.storage.file.FileRepositoryBuilder; import org.eclipse.jgit.storage.file.FileRepositoryBuilder;
import org.eclipse.jgit.treewalk.TreeWalk;
import org.eclipse.jgit.treewalk.filter.PathFilter;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import java.io.ByteArrayOutputStream;
import java.io.IOException; import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files; import java.nio.file.Files;
import java.nio.file.Path; import java.nio.file.Path;
import java.time.Instant;
import java.time.ZoneId;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List; import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.stream.Stream; import java.util.stream.Stream;
@Service @Service
@@ -109,7 +135,8 @@ public class GitService
.call() .call()
.stream() .stream()
.map(Ref::getName) .map(Ref::getName)
.map(ref -> ref.startsWith("refs/heads/") ? ref.substring("refs/heads/".length()) : ref) .filter(ref -> ref.startsWith("refs/heads/"))
.map(ref -> ref.substring("refs/heads/".length()))
.toList(); .toList();
} }
} }
@@ -122,6 +149,15 @@ public class GitService
} }
} }
public String getHeadCommitHash(String name) throws IOException
{
try (Git git = openRepository(name))
{
ObjectId head = git.getRepository().resolve("HEAD");
return head != null ? head.getName() : null;
}
}
public void checkoutBranch(String name, String branch) throws IOException, GitAPIException public void checkoutBranch(String name, String branch) throws IOException, GitAPIException
{ {
try (Git git = openRepository(name)) try (Git git = openRepository(name))
@@ -175,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))
@@ -193,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))
@@ -216,11 +304,335 @@ public class GitService
} }
} }
public Map<String, String> listRemotes(String name) throws IOException
{
try (Git git = openRepository(name))
{
StoredConfig config = git.getRepository().getConfig();
var remoteNames = config.getSubsections("remote");
Map<String, String> remotes = new LinkedHashMap<>();
for (String remote : remoteNames)
{
String url = config.getString("remote", remote, "url");
remotes.put(remote, url != null ? url : "");
}
return remotes;
}
}
public void updateRemoteUrl(String name, String remote, String url) throws IOException
{
try (Git git = openRepository(name))
{
StoredConfig config = git.getRepository().getConfig();
config.setString("remote", remote, "url", url);
config.save();
}
}
public List<CommitInfo> listCommits(String name) throws IOException
{
try (Git git = openRepository(name))
{
Repository repo = git.getRepository();
try (PlotWalk plotWalk = new PlotWalk(repo))
{
// Walk from all branch tips so we see all commits
for (Ref ref : repo.getRefDatabase().getRefsByPrefix("refs/heads/"))
{
plotWalk.markStart(plotWalk.parseCommit(ref.getObjectId()));
}
// Also include HEAD in case of detached HEAD
ObjectId head = repo.resolve("HEAD");
if (head != null)
plotWalk.markStart(plotWalk.parseCommit(head));
PlotCommitList<PlotLane> plotCommitList = new PlotCommitList<>();
plotCommitList.source(plotWalk);
plotCommitList.fillTo(Integer.MAX_VALUE);
// 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)
{
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();
DateTimeFormatter fmt = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm")
.withZone(ZoneId.systemDefault());
commits.add(new CommitInfo(
pc.getId().getName(),
pc.getId().abbreviate(7).name(),
pc.getShortMessage(),
pc.getAuthorIdent().getName(),
fmt.format(Instant.ofEpochSecond(pc.getCommitTime())),
parents,
graphLine
));
}
return commits;
}
}
}
private String buildGraphLine(PlotCommit<PlotLane> pc, Set<Integer> activeLanes)
{
int commitLane = pc.getLane() != null ? pc.getLane().getPosition() : 0;
// 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())
{
if (parent instanceof PlotCommit<?> pp && pp.getLane() != null)
{
int pLane = pp.getLane().getPosition();
if (pLane < commitLane)
mergeFromLane = Math.min(mergeFromLane, pLane);
}
}
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
{
try (Git git = openRepository(name))
{
Repository repo = git.getRepository();
ObjectId commitId = repo.resolve(commitHash);
try (var walk = new org.eclipse.jgit.revwalk.RevWalk(repo))
{
RevCommit commit = walk.parseCommit(commitId);
RevTree newTree = commit.getTree();
RevTree oldTree = null;
if (commit.getParentCount() > 0)
{
RevCommit parent = walk.parseCommit(commit.getParent(0));
oldTree = parent.getTree();
}
ByteArrayOutputStream out = new ByteArrayOutputStream();
try (DiffFormatter df = new DiffFormatter(out))
{
df.setRepository(repo);
List<DiffEntry> diffs = df.scan(oldTree, newTree);
List<DiffInfo> result = new ArrayList<>();
for (DiffEntry diff : diffs)
{
out.reset();
df.format(diff);
result.add(new DiffInfo(
diff.getChangeType().name(),
diff.getOldPath(),
diff.getNewPath(),
out.toString(StandardCharsets.UTF_8)
));
}
return result;
}
}
}
}
public List<FileInfo> listFilesAtCommit(String name, String commitHash, String dirPath) throws IOException
{
try (Git git = openRepository(name))
{
Repository repo = git.getRepository();
ObjectId commitId = repo.resolve(commitHash);
try (var walk = new org.eclipse.jgit.revwalk.RevWalk(repo))
{
RevCommit commit = walk.parseCommit(commitId);
RevTree tree = commit.getTree();
List<FileInfo> files = new ArrayList<>();
try (TreeWalk tw = new TreeWalk(repo))
{
tw.addTree(tree);
tw.setRecursive(false);
if (dirPath != null && !dirPath.isEmpty())
{
tw.setFilter(PathFilter.create(dirPath));
// Walk into the directory, entering intermediate subtrees as needed
while (tw.next())
{
if (tw.isSubtree())
{
String path = tw.getPathString();
tw.enterSubtree();
if (path.equals(dirPath))
break;
}
}
}
while (tw.next())
{
if (dirPath != null && !dirPath.isEmpty() && !tw.getPathString().startsWith(dirPath + "/"))
continue;
String type = tw.isSubtree() ? "tree" : "blob";
files.add(new FileInfo(tw.getPathString(), type));
}
}
return files;
}
}
}
public String getFileContentAtCommit(String name, String commitHash, String filePath) throws IOException
{
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))
{
Repository repo = git.getRepository();
ObjectId commitId = repo.resolve(commitHash);
try (var walk = new org.eclipse.jgit.revwalk.RevWalk(repo))
{
RevCommit commit = walk.parseCommit(commitId);
RevTree tree = commit.getTree();
try (TreeWalk tw = TreeWalk.forPath(repo, filePath, tree))
{
if (tw == null)
return null;
ObjectLoader loader = repo.open(tw.getObjectId(0));
return loader.getBytes();
}
}
}
}
public void checkoutCommit(String name, String commitHash) throws IOException, GitAPIException
{
try (Git git = openRepository(name))
{
git.checkout()
.setName(commitHash)
.call();
}
}
public void checkoutFilesFromCommit(String name, String commitHash, List<String> files) throws IOException, GitAPIException
{
try (Git git = openRepository(name))
{
var checkout = git.checkout().setStartPoint(commitHash);
for (String file : files)
{
checkout.addPath(file);
}
checkout.call();
}
}
public 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() : ""));
}
}
}
} }
} }
@@ -228,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

@@ -0,0 +1,34 @@
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<title th:text="'File - ' + ${filePath}">File</title>
</head>
<body>
<h2 th:text="${filePath}">file.txt</h2>
<p>
<a th:href="@{/repo/{name}/commits(name=${name})}">&lt; Back to commits</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">
<tr>
<td>
<form method="post" th:action="@{/repo/{name}/checkout-files(name=${name})}">
<input type="hidden" name="hash" th:value="${hash}">
<input type="hidden" name="files" th:value="${filePath}">
<input type="submit" value="Checkout this file">
</form>
</td>
</tr>
</table>
<h3>Content</h3>
<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,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

@@ -0,0 +1,66 @@
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<title th:text="'Commit ' + ${hash}">Commit</title>
</head>
<body>
<h2>Commit <span th:text="${shortHash}"></span></h2>
<p><a th:href="@{/repo/{name}/commits(name=${name})}">&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>
<form method="post" th:action="@{/repo/{name}/checkout-commit(name=${name})}">
<input type="hidden" name="hash" th:value="${hash}">
<input type="submit" value="Checkout this commit">
</form>
</td>
<td>
<a th:href="@{/repo/{name}/tree/{hash}(name=${name}, hash=${hash})}">Browse files</a>
</td>
</tr>
</table>
<h3>Changed Files</h3>
<form method="post" th:action="@{/repo/{name}/checkout-files(name=${name})}">
<input type="hidden" name="hash" th:value="${hash}">
<table border="1" cellpadding="4" cellspacing="0">
<tr>
<th></th>
<th>Change</th>
<th>File</th>
</tr>
<tr th:each="d : ${diffs}">
<td><input type="checkbox" name="files" th:value="${d.newPath}"></td>
<td th:text="${d.changeType}"></td>
<td th:text="${d.changeType == 'DELETE' ? d.oldPath : d.newPath}"></td>
</tr>
</table>
<br>
<input type="submit" value="Checkout selected files">
</form>
<h3>Diff</h3>
<th:block th:each="d : ${diffs}">
<h4 th:text="${d.changeType == 'DELETE' ? d.oldPath : d.newPath}"></h4>
<pre th:text="${d.diff}"></pre>
</th:block>
</body>
</html>

View File

@@ -0,0 +1,41 @@
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<title th:text="'Commits - ' + ${name}">Commits</title>
</head>
<body>
<h2>Commits</h2>
<table border="1" cellpadding="4" cellspacing="0">
<tr>
<th>Graph</th>
<th>Hash</th>
<th>Message</th>
<th>Author</th>
<th>Date</th>
<th></th>
<th></th>
<th></th>
</tr>
<tr th:each="c : ${commits}">
<td><pre th:text="${c.graphLine}" style="margin:0"></pre></td>
<td><a th:href="@{/repo/{name}/commit/{hash}(name=${name}, hash=${c.hash})}" th:text="${c.shortHash}"></a></td>
<td th:text="${c.message}"></td>
<td th:text="${c.author}"></td>
<td th:text="${c.date}"></td>
<td><a th:href="@{/repo/{name}/tree/{hash}(name=${name}, hash=${c.hash})}">Browse</a></td>
<td>
<span th:if="${c.hash == headHash}">(current)</span>
<form th:unless="${c.hash == headHash}" method="post" th:action="@{/repo/{name}/checkout-commit(name=${name})}">
<input type="hidden" name="hash" th:value="${c.hash}">
<input type="submit" value="Checkout">
</form>
</td>
<td><a th:href="@{/repo/{name}/commit/{hash}(name=${name}, hash=${c.hash})}">Diff</a></td>
</tr>
</table>
<p th:if="${commits.isEmpty()}">No commits yet.</p>
</body>
</html>

View File

@@ -0,0 +1,22 @@
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<title th:text="'Confirm Delete - ' + ${name}">Confirm Delete</title>
</head>
<body>
<h2>Are you sure?</h2>
<p>This will permanently delete the repository <b th:text="${name}"></b> and its working tree.</p>
<table border="0" cellpadding="4" cellspacing="0">
<tr>
<td>
<form method="post" th:action="@{/repo/{name}/delete(name=${name})}" target="_top">
<input type="submit" value="Yes">
</form>
</td>
<td>
<a th:href="@{/repo/{name}/manage(name=${name})}">No</a>
</td>
</tr>
</table>
</body>
</html>

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

@@ -4,7 +4,7 @@
</head> </head>
<frameset cols="180,*"> <frameset cols="180,*">
<frame th:src="${selectedRepo != null} ? '/nav?repo=' + ${selectedRepo} : '/nav'" name="nav"> <frame th:src="${selectedRepo != null} ? '/nav?repo=' + ${selectedRepo} : '/nav'" name="nav">
<frame th:src="${showCloneForm} ? '/clone-form' : (${selectedRepo != null} ? '/repo/' + ${selectedRepo} + '/branches' : '/welcome')" name="content"> <frame th:src="${showCloneForm} ? '/clone-form' : (${selectedRepo != null} ? '/repo/' + ${selectedRepo} + '/changes' : '/welcome')" name="content">
<noframes> <noframes>
<body> <body>
<p>Your browser does not support frames. <a href="/repos">Click here</a> to continue.</p> <p>Your browser does not support frames. <a href="/repos">Click here</a> to continue.</p>

View File

@@ -8,7 +8,7 @@
<h3>Danger Zone</h3> <h3>Danger Zone</h3>
<p>This will permanently delete the repository and its working tree.</p> <p>This will permanently delete the repository and its working tree.</p>
<form method="post" th:action="@{/repo/{name}/delete(name=${name})}" target="_top"> <form method="get" th:action="@{/repo/{name}/confirm-delete(name=${name})}">
<input type="submit" value="Delete Repository"> <input type="submit" value="Delete Repository">
</form> </form>

View File

@@ -19,8 +19,9 @@
<th:block th:if="${selectedRepo != null}"> <th:block th:if="${selectedRepo != null}">
<hr> <hr>
<b th:text="${selectedRepo}"></b><br> <b th:text="${selectedRepo}"></b><br>
<a th:href="@{/repo/{name}/changes(name=${selectedRepo})}" target="content">Staging</a><br>
<a th:href="@{/repo/{name}/commits(name=${selectedRepo})}" target="content">Commits</a><br>
<a th:href="@{/repo/{name}/branches(name=${selectedRepo})}" target="content">Branches</a><br> <a th:href="@{/repo/{name}/branches(name=${selectedRepo})}" target="content">Branches</a><br>
<a th:href="@{/repo/{name}/changes(name=${selectedRepo})}" target="content">Changes</a><br>
<a th:href="@{/repo/{name}/remote(name=${selectedRepo})}" target="content">Remote</a><br> <a th:href="@{/repo/{name}/remote(name=${selectedRepo})}" target="content">Remote</a><br>
<a th:href="@{/repo/{name}/manage(name=${selectedRepo})}" target="content">Manage</a><br> <a th:href="@{/repo/{name}/manage(name=${selectedRepo})}" target="content">Manage</a><br>
</th:block> </th:block>

View File

@@ -4,10 +4,24 @@
<title th:text="'Remote - ' + ${name}">Remote</title> <title th:text="'Remote - ' + ${name}">Remote</title>
</head> </head>
<body> <body>
<h2>Remote</h2> <h2>Remotes</h2>
<table border="0" cellpadding="4" cellspacing="0"> <table border="1" cellpadding="4" cellspacing="0">
<tr> <tr>
<th>Name</th>
<th>URL</th>
<th></th>
<th></th>
</tr>
<tr th:each="entry : ${remotes}">
<td th:text="${entry.key}"></td>
<td>
<form method="post" th:action="@{/repo/{name}/update-remote(name=${name})}">
<input type="hidden" name="remote" th:value="${entry.key}">
<input type="text" name="url" th:value="${entry.value}" size="40">
<input type="submit" value="Save">
</form>
</td>
<td> <td>
<form method="post" th:action="@{/repo/{name}/push(name=${name})}"> <form method="post" th:action="@{/repo/{name}/push(name=${name})}">
<input type="submit" value="Push"> <input type="submit" value="Push">
@@ -21,5 +35,7 @@
</tr> </tr>
</table> </table>
<p th:if="${remotes.isEmpty()}">No remotes configured.</p>
</body> </body>
</html> </html>

View File

@@ -0,0 +1,35 @@
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<title th:text="'Browse - ' + ${shortHash}">Browse</title>
</head>
<body>
<h2>Browse <span th:text="${shortHash}"></span></h2>
<p>
<a th:href="@{/repo/{name}/commits(name=${name})}">&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>
<th>Name</th>
</tr>
<tr th:each="f : ${files}">
<td th:text="${f.type}"></td>
<td>
<a th:if="${f.type == 'tree'}" th:href="@{/repo/{name}/tree/{hash}(name=${name}, hash=${hash}, path=${f.path})}" th:text="${f.path.contains('/') ? f.path.substring(f.path.lastIndexOf('/') + 1) : f.path}"></a>
<a th:if="${f.type == 'blob'}" th:href="@{'/repo/' + ${name} + '/blob/' + ${hash} + '/' + ${f.path}}" th:text="${f.path.contains('/') ? f.path.substring(f.path.lastIndexOf('/') + 1) : f.path}"></a>
</td>
</tr>
</table>
<p th:if="${files.isEmpty()}">Empty directory.</p>
</body>
</html>

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.*;
@@ -25,11 +27,11 @@ class RepoControllerTest
private GitService gitService; private GitService gitService;
@Test @Test
void repoRedirectsToBranches() throws Exception void repoRedirectsToChanges() throws Exception
{ {
mockMvc.perform(get("/repo/myrepo")) mockMvc.perform(get("/repo/myrepo"))
.andExpect(status().is3xxRedirection()) .andExpect(status().is3xxRedirection())
.andExpect(redirectedUrl("/repo/myrepo/branches")); .andExpect(redirectedUrl("/repo/myrepo/changes"));
} }
@Test @Test
@@ -74,12 +76,26 @@ class RepoControllerTest
} }
@Test @Test
void remotePageLoads() throws Exception void remotePageShowsRemotes() throws Exception
{ {
when(gitService.listRemotes("myrepo")).thenReturn(java.util.Map.of("origin", "https://example.com/repo.git"));
mockMvc.perform(get("/repo/myrepo/remote")) mockMvc.perform(get("/repo/myrepo/remote"))
.andExpect(status().isOk()) .andExpect(status().isOk())
.andExpect(view().name("remote")) .andExpect(view().name("remote"))
.andExpect(model().attribute("name", "myrepo")); .andExpect(model().attribute("name", "myrepo"))
.andExpect(content().string(org.hamcrest.Matchers.containsString("origin")))
.andExpect(content().string(org.hamcrest.Matchers.containsString("https://example.com/repo.git")));
}
@Test
void remotePageShowsNoRemotes() throws Exception
{
when(gitService.listRemotes("myrepo")).thenReturn(java.util.Map.of());
mockMvc.perform(get("/repo/myrepo/remote"))
.andExpect(status().isOk())
.andExpect(content().string(org.hamcrest.Matchers.containsString("No remotes configured.")));
} }
@Test @Test
@@ -125,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
{ {
@@ -168,6 +196,28 @@ class RepoControllerTest
verify(gitService).pull("myrepo"); verify(gitService).pull("myrepo");
} }
@Test
void updateRemoteRedirectsToRemote() throws Exception
{
mockMvc.perform(post("/repo/myrepo/update-remote")
.param("remote", "origin")
.param("url", "https://new-url.com/repo.git"))
.andExpect(status().is3xxRedirection())
.andExpect(redirectedUrl("/repo/myrepo/remote"));
verify(gitService).updateRemoteUrl("myrepo", "origin", "https://new-url.com/repo.git");
}
@Test
void confirmDeleteShowsConfirmation() throws Exception
{
mockMvc.perform(get("/repo/myrepo/confirm-delete"))
.andExpect(status().isOk())
.andExpect(view().name("confirm-delete"))
.andExpect(model().attribute("name", "myrepo"))
.andExpect(content().string(org.hamcrest.Matchers.containsString("Are you sure?")));
}
@Test @Test
void deleteRedirectsToRoot() throws Exception void deleteRedirectsToRoot() throws Exception
{ {
@@ -177,4 +227,202 @@ class RepoControllerTest
verify(gitService).deleteRepository("myrepo"); verify(gitService).deleteRepository("myrepo");
} }
@Test
void commitsPageShowsCommits() throws Exception
{
when(gitService.listCommits("myrepo")).thenReturn(List.of(
new be.seeseepuff.webgit.model.CommitInfo("abc1234567890", "abc1234", "Initial commit", "author", "2026-01-01 12:00", List.of(), "*")
));
mockMvc.perform(get("/repo/myrepo/commits"))
.andExpect(status().isOk())
.andExpect(view().name("commits"))
.andExpect(model().attribute("name", "myrepo"))
.andExpect(content().string(org.hamcrest.Matchers.containsString("abc1234")))
.andExpect(content().string(org.hamcrest.Matchers.containsString("Initial commit")));
}
@Test
void commitDetailShowsDiff() throws Exception
{
when(gitService.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")
));
mockMvc.perform(get("/repo/myrepo/commit/abc1234"))
.andExpect(status().isOk())
.andExpect(view().name("commit"))
.andExpect(model().attribute("hash", "abc1234"))
.andExpect(content().string(org.hamcrest.Matchers.containsString("Fix bug")))
.andExpect(content().string(org.hamcrest.Matchers.containsString("file.txt")));
}
@Test
void treeShowsFiles() throws Exception
{
when(gitService.listFilesAtCommit("myrepo", "abc1234", "")).thenReturn(List.of(
new be.seeseepuff.webgit.model.FileInfo("README.md", "blob")
));
mockMvc.perform(get("/repo/myrepo/tree/abc1234"))
.andExpect(status().isOk())
.andExpect(view().name("tree"))
.andExpect(content().string(org.hamcrest.Matchers.containsString("README.md")));
}
@Test
void blobShowsFileContent() throws Exception
{
when(gitService.getFileContentAtCommit("myrepo", "abc1234", "README.md")).thenReturn("# Hello");
mockMvc.perform(get("/repo/myrepo/blob/abc1234/README.md"))
.andExpect(status().isOk())
.andExpect(view().name("blob"))
.andExpect(content().string(org.hamcrest.Matchers.containsString("# Hello")));
}
@Test
void 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
{
mockMvc.perform(post("/repo/myrepo/checkout-commit")
.param("hash", "abc1234"))
.andExpect(status().is3xxRedirection())
.andExpect(redirectedUrl("/repo/myrepo/commits"));
verify(gitService).checkoutCommit("myrepo", "abc1234");
}
@Test
void checkoutFilesRedirectsToChanges() throws Exception
{
mockMvc.perform(post("/repo/myrepo/checkout-files")
.param("hash", "abc1234")
.param("files", "a.txt")
.param("files", "b.txt"))
.andExpect(status().is3xxRedirection())
.andExpect(redirectedUrl("/repo/myrepo/changes"));
verify(gitService).checkoutFilesFromCommit("myrepo", "abc1234", List.of("a.txt", "b.txt"));
}
@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

@@ -311,4 +311,322 @@ class GitServiceTest
{ {
assertThrows(IllegalArgumentException.class, () -> gitService.cloneRepository(".git", null)); assertThrows(IllegalArgumentException.class, () -> gitService.cloneRepository(".git", null));
} }
@Test
void listRemotesReturnsConfiguredRemotes() throws GitAPIException, IOException
{
gitService.cloneRepository(bareRemote.toUri().toString(), "myrepo");
var remotes = gitService.listRemotes("myrepo");
assertEquals(1, remotes.size());
assertTrue(remotes.containsKey("origin"));
assertEquals(bareRemote.toUri().toString(), remotes.get("origin"));
}
@Test
void updateRemoteUrlChangesUrl() throws GitAPIException, IOException
{
gitService.cloneRepository(bareRemote.toUri().toString(), "myrepo");
gitService.updateRemoteUrl("myrepo", "origin", "https://new-url.com/repo.git");
var remotes = gitService.listRemotes("myrepo");
assertEquals("https://new-url.com/repo.git", remotes.get("origin"));
}
@Test
void listCommitsReturnsCommitHistory() throws GitAPIException, IOException
{
gitService.cloneRepository(bareRemote.toUri().toString(), "myrepo");
var commits = gitService.listCommits("myrepo");
assertFalse(commits.isEmpty());
assertEquals("Initial commit", commits.getFirst().message());
assertNotNull(commits.getFirst().hash());
assertNotNull(commits.getFirst().shortHash());
assertNotNull(commits.getFirst().author());
assertNotNull(commits.getFirst().date());
assertNotNull(commits.getFirst().graphLine());
}
@Test
void 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
{
gitService.cloneRepository(bareRemote.toUri().toString(), "myrepo");
var commits = gitService.listCommits("myrepo");
var diffs = gitService.getCommitDiff("myrepo", commits.getFirst().hash());
assertFalse(diffs.isEmpty());
assertEquals("ADD", diffs.getFirst().changeType());
assertEquals("README.md", diffs.getFirst().newPath());
assertFalse(diffs.getFirst().diff().isEmpty());
}
@Test
void listFilesAtCommitReturnsFiles() throws GitAPIException, IOException
{
gitService.cloneRepository(bareRemote.toUri().toString(), "myrepo");
var commits = gitService.listCommits("myrepo");
var files = gitService.listFilesAtCommit("myrepo", commits.getFirst().hash(), "");
assertFalse(files.isEmpty());
assertTrue(files.stream().anyMatch(f -> f.path().equals("README.md")));
}
@Test
void 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
{
gitService.cloneRepository(bareRemote.toUri().toString(), "myrepo");
var commits = gitService.listCommits("myrepo");
String content = gitService.getFileContentAtCommit("myrepo", commits.getFirst().hash(), "README.md");
assertEquals("# Test", content);
}
@Test
void 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
{
gitService.cloneRepository(bareRemote.toUri().toString(), "myrepo");
var commits = gitService.listCommits("myrepo");
String content = gitService.getFileContentAtCommit("myrepo", commits.getFirst().hash(), "nonexistent.txt");
assertNull(content);
}
@Test
void checkoutCommitDetachesHead() throws GitAPIException, IOException
{
gitService.cloneRepository(bareRemote.toUri().toString(), "myrepo");
var commits = gitService.listCommits("myrepo");
gitService.checkoutCommit("myrepo", commits.getFirst().hash());
String head = gitService.getCurrentBranch("myrepo");
// Detached HEAD returns the commit hash
assertTrue(head.matches("[0-9a-f]+"));
}
@Test
void checkoutFilesFromCommitRestoresFiles() throws GitAPIException, IOException
{
gitService.cloneRepository(bareRemote.toUri().toString(), "myrepo");
// Modify a file
Path readme = worktreePath.resolve("myrepo").resolve("README.md");
Files.writeString(readme, "modified");
var commits = gitService.listCommits("myrepo");
gitService.checkoutFilesFromCommit("myrepo", commits.getFirst().hash(), List.of("README.md"));
assertEquals("# Test", Files.readString(readme));
}
@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"));
}
} }