Compare commits
30 Commits
ba3bf697f0
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 4077c1b28e | |||
| e6f6e2466b | |||
| 5be1b1cc29 | |||
| 36ecd019a8 | |||
| 472db2dd96 | |||
| b5097685c7 | |||
| 6a532322c4 | |||
| 52fe455c76 | |||
| a27c9fba00 | |||
| 9b1668def9 | |||
| ec79a0c5cf | |||
| 2dcacdbe8d | |||
| e079eed52d | |||
| d803919bf5 | |||
| 98de13b410 | |||
| 8784dfc391 | |||
| b0016767e8 | |||
| 04a69c323e | |||
| eb222716cd | |||
| d68933bc2f | |||
| b0c869829f | |||
| 321e268530 | |||
| 1b6f007eea | |||
| b2b3993d85 | |||
| fbfffac73f | |||
| fdc520cfaf | |||
| 383864469d | |||
| 4458eb204b | |||
| 005e0c7d23 | |||
| be130582fc |
22
.gitea/workflows/build.yml
Normal file
22
.gitea/workflows/build.yml
Normal 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
|
||||||
38
.gitea/workflows/deploy.yml
Normal file
38
.gitea/workflows/deploy.yml
Normal 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
26
.github/copilot-instructions.md
vendored
Normal 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
5
Dockerfile
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
FROM eclipse-temurin:25-alpine
|
||||||
|
WORKDIR /app
|
||||||
|
ADD ./build/libs/webgit-0.0.1-SNAPSHOT.jar /app/webgit.jar
|
||||||
|
ENTRYPOINT ["java", "-jar", "webgit.jar"]
|
||||||
|
EXPOSE 8080/tcp
|
||||||
@@ -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:
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
{
|
{
|
||||||
|
|||||||
15
src/main/java/be/seeseepuff/webgit/model/CommitInfo.java
Normal file
15
src/main/java/be/seeseepuff/webgit/model/CommitInfo.java
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
package be.seeseepuff.webgit.model;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
public record CommitInfo(
|
||||||
|
String hash,
|
||||||
|
String shortHash,
|
||||||
|
String message,
|
||||||
|
String author,
|
||||||
|
String date,
|
||||||
|
List<String> parentHashes,
|
||||||
|
String graphLine
|
||||||
|
)
|
||||||
|
{
|
||||||
|
}
|
||||||
10
src/main/java/be/seeseepuff/webgit/model/DiffInfo.java
Normal file
10
src/main/java/be/seeseepuff/webgit/model/DiffInfo.java
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
package be.seeseepuff.webgit.model;
|
||||||
|
|
||||||
|
public record DiffInfo(
|
||||||
|
String changeType,
|
||||||
|
String oldPath,
|
||||||
|
String newPath,
|
||||||
|
String diff
|
||||||
|
)
|
||||||
|
{
|
||||||
|
}
|
||||||
8
src/main/java/be/seeseepuff/webgit/model/FileInfo.java
Normal file
8
src/main/java/be/seeseepuff/webgit/model/FileInfo.java
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
package be.seeseepuff.webgit.model;
|
||||||
|
|
||||||
|
public record FileInfo(
|
||||||
|
String path,
|
||||||
|
String type
|
||||||
|
)
|
||||||
|
{
|
||||||
|
}
|
||||||
@@ -1,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());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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=
|
||||||
|
|||||||
34
src/main/resources/templates/blob.html
Normal file
34
src/main/resources/templates/blob.html
Normal 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})}">< Back to commits</a>
|
||||||
|
|
|
||||||
|
<a th:href="@{/repo/{name}/tree/{hash}(name=${name}, hash=${hash}, path=${parentPath})}">< Parent directory</a>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<table border="0" cellpadding="4" cellspacing="0">
|
||||||
|
<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>
|
||||||
@@ -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>
|
||||||
|
|||||||
66
src/main/resources/templates/commit.html
Normal file
66
src/main/resources/templates/commit.html
Normal 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})}">< 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>
|
||||||
41
src/main/resources/templates/commits.html
Normal file
41
src/main/resources/templates/commits.html
Normal 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>
|
||||||
22
src/main/resources/templates/confirm-delete.html
Normal file
22
src/main/resources/templates/confirm-delete.html
Normal 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>
|
||||||
16
src/main/resources/templates/file-diff.html
Normal file
16
src/main/resources/templates/file-diff.html
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html xmlns:th="http://www.thymeleaf.org">
|
||||||
|
<head>
|
||||||
|
<title th:text="'Diff - ' + ${filePath}">Diff</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h2 th:text="${filePath}">file.txt</h2>
|
||||||
|
|
||||||
|
<p><a th:href="@{/repo/{name}/changes(name=${name})}">< Back to staging</a></p>
|
||||||
|
|
||||||
|
<pre th:text="${diff}">Diff output here</pre>
|
||||||
|
|
||||||
|
<p th:if="${diff.isEmpty()}"><i>No differences found.</i></p>
|
||||||
|
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
35
src/main/resources/templates/tree.html
Normal file
35
src/main/resources/templates/tree.html
Normal 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})}">< Back to commits</a>
|
||||||
|
<span th:if="${!path.isEmpty()}">
|
||||||
|
| <a th:href="@{/repo/{name}/tree/{hash}(name=${name}, hash=${hash}, path=${parentPath})}">< Parent directory</a>
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p th:if="${!path.isEmpty()}">Path: <b th:text="${path}"></b></p>
|
||||||
|
|
||||||
|
<table border="1" cellpadding="4" cellspacing="0">
|
||||||
|
<tr>
|
||||||
|
<th>Type</th>
|
||||||
|
<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>
|
||||||
@@ -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"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user