Compare commits

..

40 Commits

Author SHA1 Message Date
seeseemelk 798adb5299 Merge branch 'master' into renovate/spring-boot
Build and Test / build (pull_request) Successful in 1m37s
2026-05-03 11:03:19 +02:00
seeseemelk a2638b0c24 Update .gitea/workflows/build.yml
Deploy / build (push) Successful in 2m4s
2026-05-03 11:03:14 +02:00
seeseemelk e4a01db90d Merge branch 'master' into renovate/spring-boot 2026-05-03 11:02:02 +02:00
seeseemelk 5b6185b236 Merge pull request 'Update dependency org.eclipse.jgit:org.eclipse.jgit to v7.6.0.202603022253-r' (#2) from renovate/org.eclipse.jgit-org.eclipse.jgit-7.x into master
Deploy / build (push) Successful in 2m42s
Reviewed-on: #2
2026-05-02 23:03:29 +02:00
seeseemelk 3c7444675d Merge branch 'master' into renovate/org.eclipse.jgit-org.eclipse.jgit-7.x 2026-05-02 23:03:22 +02:00
seeseemelk 7610f5d97f Merge pull request 'Update Gradle to v9.5.0' (#4) from renovate/gradle-9.x into master
Deploy / build (push) Has been cancelled
Reviewed-on: #4
2026-05-02 23:03:13 +02:00
seeseemelk 53cb54c662 Merge branch 'master' into renovate/gradle-9.x 2026-05-02 23:03:07 +02:00
seeseemelk 82ed9fb77a Merge pull request 'Update actions/checkout action to v6' (#5) from renovate/actions-checkout-6.x into master
Deploy / build (push) Has been cancelled
Reviewed-on: #5
2026-05-02 23:03:01 +02:00
seeseemelk b9c637f0ba Merge branch 'master' into renovate/actions-checkout-6.x 2026-05-02 23:02:55 +02:00
seeseemelk fac9062f83 Merge pull request 'Update actions/setup-java action to v5' (#6) from renovate/actions-setup-java-5.x into master
Deploy / build (push) Has been cancelled
Reviewed-on: #6
2026-05-02 23:02:49 +02:00
Renovate a314b88eb3 Update actions/setup-java action to v5 2026-05-02 21:02:05 +00:00
Renovate b3974c15f2 Update actions/checkout action to v6 2026-05-02 21:02:04 +00:00
Renovate 768587ce57 Update Gradle to v9.5.0 2026-05-02 21:02:02 +00:00
Renovate 7c595ef022 Update dependency org.eclipse.jgit:org.eclipse.jgit to v7.6.0.202603022253-r 2026-05-02 20:59:17 +00:00
Renovate 270223dd9f Update plugin org.springframework.boot to v4.0.6 2026-05-02 20:59:16 +00:00
seeseemelk 0e8ab5f4ef Add renovate.json
Deploy / build (push) Has been cancelled
2026-05-02 22:58:10 +02:00
seeseemelk 4077c1b28e Log test results
Deploy / build (push) Successful in 1m41s
2026-02-27 22:02:13 +01:00
seeseemelk e6f6e2466b Add test step
Deploy / build (push) Successful in 1m4s
2026-02-27 21:56:51 +01:00
seeseemelk 5be1b1cc29 Throw exception on authentication issues 2026-02-27 21:54:30 +01:00
seeseemelk 36ecd019a8 Add copilot instructions 2026-02-27 21:53:56 +01:00
seeseemelk 472db2dd96 Incrase spacing in staging view
Deploy / build (push) Successful in 50s
2026-02-27 19:31:10 +01:00
seeseemelk b5097685c7 Put push/pull buttons on one line
Deploy / build (push) Successful in 49s
2026-02-27 19:29:08 +01:00
seeseemelk 6a532322c4 Add credential settings
Deploy / build (push) Successful in 55s
2026-02-27 19:22:38 +01:00
seeseemelk 52fe455c76 Add ahead/behind indicator and push/pull buttons 2026-02-27 19:22:03 +01:00
seeseemelk a27c9fba00 Add watchtower step to workflow
Deploy / build (push) Successful in 46s
2026-02-27 19:08:42 +01:00
seeseemelk 9b1668def9 Add checkmark to mark all files
Deploy / build (push) Successful in 46s
2026-02-27 19:06:00 +01:00
seeseemelk ec79a0c5cf Remove duplicate Push Container step in deploy workflow
Deploy / build (push) Successful in 32s
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-02-27 11:35:11 +01:00
seeseemelk 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
seeseemelk e079eed52d Use ubuntu-latest runner in CI/CD workflows
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
seeseemelk d803919bf5 Update deploy pipeline: run on master, tag as latest
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
seeseemelk 98de13b410 Add Dockerfile and Gitea CI/CD workflows
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
seeseemelk 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
seeseemelk 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
seeseemelk 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
seeseemelk 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
seeseemelk 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
seeseemelk 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
seeseemelk 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
seeseemelk 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
seeseemelk 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
22 changed files with 811 additions and 76 deletions
+22
View File
@@ -0,0 +1,22 @@
name: Build and Test
on:
pull_request:
branches:
- master
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Setup Java
uses: actions/setup-java@v5
with:
distribution: 'temurin'
java-version: '25'
cache: 'gradle'
- name: Build and Test
run: ./gradlew build --no-daemon
+38
View File
@@ -0,0 +1,38 @@
name: Deploy
on:
push:
branches:
- master
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Setup Java
uses: actions/setup-java@v5
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
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
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
+4
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:
+7 -2
View File
@@ -1,7 +1,7 @@
plugins { plugins {
java java
jacoco jacoco
id("org.springframework.boot") version "4.0.3" id("org.springframework.boot") version "4.0.6"
id("io.spring.dependency-management") version "1.1.7" id("io.spring.dependency-management") version "1.1.7"
} }
@@ -29,7 +29,7 @@ dependencies {
implementation("org.springframework.boot:spring-boot-starter-actuator") implementation("org.springframework.boot:spring-boot-starter-actuator")
implementation("org.springframework.boot:spring-boot-starter-thymeleaf") implementation("org.springframework.boot:spring-boot-starter-thymeleaf")
implementation("org.springframework.boot:spring-boot-starter-webmvc") implementation("org.springframework.boot:spring-boot-starter-webmvc")
implementation("org.eclipse.jgit:org.eclipse.jgit:7.2.0.202503040940-r") implementation("org.eclipse.jgit:org.eclipse.jgit:7.6.0.202603022253-r")
compileOnly("org.projectlombok:lombok") compileOnly("org.projectlombok:lombok")
developmentOnly("org.springframework.boot:spring-boot-devtools") developmentOnly("org.springframework.boot:spring-boot-devtools")
runtimeOnly("io.micrometer:micrometer-registry-prometheus") runtimeOnly("io.micrometer:micrometer-registry-prometheus")
@@ -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 {
Binary file not shown.
+3 -1
View File
@@ -1,7 +1,9 @@
distributionBase=GRADLE_USER_HOME distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-9.3.1-bin.zip distributionUrl=https\://services.gradle.org/distributions/gradle-9.5.0-bin.zip
networkTimeout=10000 networkTimeout=10000
retries=0
retryBackOffMs=500
validateDistributionUrl=true validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists zipStorePath=wrapper/dists
Vendored
+1 -1
View File
@@ -57,7 +57,7 @@
# Darwin, MinGW, and NonStop. # Darwin, MinGW, and NonStop.
# #
# (3) This script is generated from the Groovy template # (3) This script is generated from the Groovy template
# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt # https://github.com/gradle/gradle/blob/3d91ce3b8caaf77ad09f381f43615b715b53f72c/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project. # within the Gradle project.
# #
# You can find Gradle at https://github.com/gradle/gradle/. # You can find Gradle at https://github.com/gradle/gradle/.
Vendored
+10 -21
View File
@@ -23,8 +23,8 @@
@rem @rem
@rem ########################################################################## @rem ##########################################################################
@rem Set local scope for the variables with windows NT shell @rem Set local scope for the variables, and ensure extensions are enabled
if "%OS%"=="Windows_NT" setlocal setlocal EnableExtensions
set DIRNAME=%~dp0 set DIRNAME=%~dp0
if "%DIRNAME%"=="" set DIRNAME=. if "%DIRNAME%"=="" set DIRNAME=.
@@ -51,7 +51,7 @@ echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2 echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2 echo location of your Java installation. 1>&2
goto fail "%COMSPEC%" /c exit 1
:findJavaFromJavaHome :findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=% set JAVA_HOME=%JAVA_HOME:"=%
@@ -65,7 +65,7 @@ echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2 echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2 echo location of your Java installation. 1>&2
goto fail "%COMSPEC%" /c exit 1
:execute :execute
@rem Setup the command line @rem Setup the command line
@@ -73,21 +73,10 @@ goto fail
@rem Execute Gradle @rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* @rem endlocal doesn't take effect until after the line is parsed and variables are expanded
@rem which allows us to clear the local environment before executing the java command
endlocal & "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* & call :exitWithErrorLevel
:end :exitWithErrorLevel
@rem End local scope for the variables with windows NT shell @rem Use "%COMSPEC%" /c exit to allow operators to work properly in scripts
if %ERRORLEVEL% equ 0 goto mainEnd "%COMSPEC%" /c exit %ERRORLEVEL%
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
set EXIT_CODE=%ERRORLEVEL%
if %EXIT_CODE% equ 0 set EXIT_CODE=1
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
exit /b %EXIT_CODE%
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega
+6
View File
@@ -0,0 +1,6 @@
{
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
"extends": [
"config:recommended"
]
}
@@ -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,11 +14,25 @@ import org.springframework.web.bind.annotation.RequestParam;
import java.io.IOException; import java.io.IOException;
import java.util.List; import java.util.List;
import java.util.Map;
import java.util.Set;
@Controller @Controller
@RequiredArgsConstructor @RequiredArgsConstructor
public class RepoController public class RepoController
{ {
private static final Set<String> IMAGE_EXTENSIONS = Set.of("png", "jpg", "jpeg", "gif", "bmp", "webp", "svg", "ico");
private static final Map<String, String> IMAGE_MIME_TYPES = Map.of(
"png", "image/png",
"jpg", "image/jpeg",
"jpeg", "image/jpeg",
"gif", "image/gif",
"bmp", "image/bmp",
"webp", "image/webp",
"svg", "image/svg+xml",
"ico", "image/x-icon"
);
private final GitService gitService; private final GitService gitService;
@GetMapping("/repo/{name}") @GetMapping("/repo/{name}")
@@ -38,11 +54,31 @@ public class RepoController
public String changes(@PathVariable String name, Model model) throws IOException, GitAPIException public String changes(@PathVariable String name, Model model) throws IOException, GitAPIException
{ {
model.addAttribute("name", name); model.addAttribute("name", name);
model.addAttribute("branch", gitService.getCurrentBranch(name));
model.addAttribute("modifiedFiles", gitService.getModifiedFiles(name)); model.addAttribute("modifiedFiles", gitService.getModifiedFiles(name));
model.addAttribute("stagedFiles", gitService.getStagedFiles(name)); model.addAttribute("stagedFiles", gitService.getStagedFiles(name));
int[] aheadBehind = gitService.getAheadBehind(name);
if (aheadBehind != null)
{
model.addAttribute("commitsAhead", aheadBehind[0]);
model.addAttribute("commitsBehind", aheadBehind[1]);
}
return "changes"; return "changes";
} }
@GetMapping("/repo/{name}/diff/{filePath}/**")
public String fileDiff(@PathVariable String name,
jakarta.servlet.http.HttpServletRequest request, Model model) throws IOException
{
String fullPath = request.getRequestURI();
String prefix = "/repo/" + name + "/diff/";
String filePath = fullPath.substring(prefix.length());
model.addAttribute("name", name);
model.addAttribute("filePath", filePath);
model.addAttribute("diff", gitService.getWorkingTreeDiff(name, filePath));
return "file-diff";
}
@GetMapping("/repo/{name}/remote") @GetMapping("/repo/{name}/remote")
public String remote(@PathVariable String name, Model model) throws IOException public String remote(@PathVariable String name, Model model) throws IOException
{ {
@@ -73,6 +109,14 @@ public class RepoController
model.addAttribute("name", name); model.addAttribute("name", name);
model.addAttribute("hash", hash); model.addAttribute("hash", hash);
model.addAttribute("shortHash", hash.substring(0, Math.min(7, hash.length()))); model.addAttribute("shortHash", hash.substring(0, Math.min(7, hash.length())));
var info = gitService.getCommitInfo(name, hash);
model.addAttribute("commitInfo", info);
String fullMsg = info.message() != null ? info.message().trim() : "";
int newline = fullMsg.indexOf('\n');
String title = newline >= 0 ? fullMsg.substring(0, newline).trim() : fullMsg;
String body = newline >= 0 ? fullMsg.substring(newline).trim() : "";
model.addAttribute("commitTitle", title);
model.addAttribute("commitBody", body);
model.addAttribute("diffs", gitService.getCommitDiff(name, hash)); model.addAttribute("diffs", gitService.getCommitDiff(name, hash));
return "commit"; return "commit";
} }
@@ -85,6 +129,7 @@ public class RepoController
model.addAttribute("hash", hash); model.addAttribute("hash", hash);
model.addAttribute("shortHash", hash.substring(0, Math.min(7, hash.length()))); model.addAttribute("shortHash", hash.substring(0, Math.min(7, hash.length())));
model.addAttribute("path", path); model.addAttribute("path", path);
model.addAttribute("parentPath", path.contains("/") ? path.substring(0, path.lastIndexOf('/')) : "");
model.addAttribute("files", gitService.listFilesAtCommit(name, hash, path)); model.addAttribute("files", gitService.listFilesAtCommit(name, hash, path));
return "tree"; return "tree";
} }
@@ -96,13 +141,34 @@ public class RepoController
String fullPath = request.getRequestURI(); String fullPath = request.getRequestURI();
String prefix = "/repo/" + name + "/blob/" + hash + "/"; String prefix = "/repo/" + name + "/blob/" + hash + "/";
String filePath = fullPath.substring(prefix.length()); String filePath = fullPath.substring(prefix.length());
String ext = filePath.contains(".") ? filePath.substring(filePath.lastIndexOf('.') + 1).toLowerCase() : "";
boolean isImage = IMAGE_EXTENSIONS.contains(ext);
String parentPath = filePath.contains("/") ? filePath.substring(0, filePath.lastIndexOf('/')) : "";
model.addAttribute("name", name); model.addAttribute("name", name);
model.addAttribute("hash", hash); model.addAttribute("hash", hash);
model.addAttribute("filePath", filePath); model.addAttribute("filePath", filePath);
model.addAttribute("content", gitService.getFileContentAtCommit(name, hash, filePath)); model.addAttribute("parentPath", parentPath);
model.addAttribute("isImage", isImage);
if (!isImage)
model.addAttribute("content", gitService.getFileContentAtCommit(name, hash, filePath));
return "blob"; return "blob";
} }
@GetMapping("/repo/{name}/raw/{hash}/**")
public ResponseEntity<byte[]> rawFile(@PathVariable String name, @PathVariable String hash,
jakarta.servlet.http.HttpServletRequest request) throws IOException
{
String fullPath = request.getRequestURI();
String prefix = "/repo/" + name + "/raw/" + hash + "/";
String filePath = fullPath.substring(prefix.length());
byte[] bytes = gitService.getRawFileAtCommit(name, hash, filePath);
if (bytes == null)
return ResponseEntity.notFound().build();
String ext = filePath.contains(".") ? filePath.substring(filePath.lastIndexOf('.') + 1).toLowerCase() : "";
String mimeType = IMAGE_MIME_TYPES.getOrDefault(ext, MediaType.APPLICATION_OCTET_STREAM_VALUE);
return ResponseEntity.ok().contentType(MediaType.parseMediaType(mimeType)).body(bytes);
}
@PostMapping("/repo/{name}/checkout-commit") @PostMapping("/repo/{name}/checkout-commit")
public String checkoutCommit(@PathVariable String name, @RequestParam String hash) throws IOException, GitAPIException public String checkoutCommit(@PathVariable String name, @RequestParam String hash) throws IOException, GitAPIException
{ {
@@ -133,16 +199,26 @@ public class RepoController
} }
@PostMapping("/repo/{name}/stage") @PostMapping("/repo/{name}/stage")
public String stage(@PathVariable String name, @RequestParam List<String> files) throws IOException, GitAPIException public String stage(@PathVariable String name,
@RequestParam(required = false) List<String> files,
@RequestParam(required = false) String selectAll,
@RequestParam(defaultValue = "Stage Selected") String action) throws IOException, GitAPIException
{ {
gitService.stageFiles(name, files); List<String> filesToProcess = (selectAll != null) ? gitService.getModifiedFiles(name) : (files != null ? files : List.of());
if ("Rollback Selected".equals(action))
gitService.rollbackFiles(name, filesToProcess);
else
gitService.stageFiles(name, filesToProcess);
return "redirect:/repo/" + name + "/changes"; return "redirect:/repo/" + name + "/changes";
} }
@PostMapping("/repo/{name}/unstage") @PostMapping("/repo/{name}/unstage")
public String unstage(@PathVariable String name, @RequestParam List<String> files) throws IOException, GitAPIException public String unstage(@PathVariable String name,
@RequestParam(required = false) List<String> files,
@RequestParam(required = false) String selectAll) throws IOException, GitAPIException
{ {
gitService.unstageFiles(name, files); List<String> filesToProcess = (selectAll != null) ? gitService.getStagedFiles(name) : (files != null ? files : List.of());
gitService.unstageFiles(name, filesToProcess);
return "redirect:/repo/" + name + "/changes"; return "redirect:/repo/" + name + "/changes";
} }
@@ -154,17 +230,19 @@ public class RepoController
} }
@PostMapping("/repo/{name}/push") @PostMapping("/repo/{name}/push")
public String push(@PathVariable String name) throws IOException, GitAPIException public String push(@PathVariable String name,
@RequestParam(required = false, defaultValue = "remote") String redirectTo) throws IOException, GitAPIException
{ {
gitService.push(name); gitService.push(name);
return "redirect:/repo/" + name + "/remote"; return "redirect:/repo/" + name + "/" + redirectTo;
} }
@PostMapping("/repo/{name}/pull") @PostMapping("/repo/{name}/pull")
public String pull(@PathVariable String name) throws IOException, GitAPIException public String pull(@PathVariable String name,
@RequestParam(required = false, defaultValue = "remote") String redirectTo) throws IOException, GitAPIException
{ {
gitService.pull(name); gitService.pull(name);
return "redirect:/repo/" + name + "/remote"; return "redirect:/repo/" + name + "/" + redirectTo;
} }
@PostMapping("/repo/{name}/update-remote") @PostMapping("/repo/{name}/update-remote")
@@ -36,8 +36,10 @@ import java.time.format.DateTimeFormatter;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Arrays; import java.util.Arrays;
import java.util.LinkedHashMap; import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Set;
import java.util.stream.Stream; import java.util.stream.Stream;
@Service @Service
@@ -209,6 +211,45 @@ public class GitService
} }
} }
public String getWorkingTreeDiff(String name, String filePath) throws IOException
{
try (Git git = openRepository(name))
{
Repository repo = git.getRepository();
ByteArrayOutputStream out = new ByteArrayOutputStream();
try (DiffFormatter df = new DiffFormatter(out))
{
df.setRepository(repo);
df.setPathFilter(PathFilter.create(filePath));
// Unstaged: index vs working tree
var dirCache = new org.eclipse.jgit.dircache.DirCacheIterator(repo.readDirCache());
var workTree = new org.eclipse.jgit.treewalk.FileTreeIterator(repo);
List<DiffEntry> unstaged = df.scan(dirCache, workTree);
for (DiffEntry diff : unstaged)
{
df.format(diff);
}
// Staged: HEAD vs index
ObjectId headTree = repo.resolve("HEAD^{tree}");
if (headTree != null)
{
var headIter = new org.eclipse.jgit.treewalk.CanonicalTreeParser();
try (var reader = repo.newObjectReader())
{
headIter.reset(reader, headTree);
}
var indexIter = new org.eclipse.jgit.dircache.DirCacheIterator(repo.readDirCache());
List<DiffEntry> staged = df.scan(headIter, indexIter);
for (DiffEntry diff : staged)
{
df.format(diff);
}
}
}
return out.toString(StandardCharsets.UTF_8);
}
}
public void stageFiles(String name, List<String> files) throws IOException, GitAPIException public void stageFiles(String name, List<String> files) throws IOException, GitAPIException
{ {
try (Git git = openRepository(name)) try (Git git = openRepository(name))
@@ -227,6 +268,19 @@ public class GitService
} }
} }
public void rollbackFiles(String name, List<String> files) throws IOException, GitAPIException
{
try (Git git = openRepository(name))
{
var checkout = git.checkout().setStartPoint("HEAD");
for (String file : files)
{
checkout.addPath(file);
}
checkout.call();
}
}
public void commit(String name, String message) throws IOException, GitAPIException public void commit(String name, String message) throws IOException, GitAPIException
{ {
try (Git git = openRepository(name)) try (Git git = openRepository(name))
@@ -297,17 +351,25 @@ public class GitService
plotCommitList.source(plotWalk); plotCommitList.source(plotWalk);
plotCommitList.fillTo(Integer.MAX_VALUE); plotCommitList.fillTo(Integer.MAX_VALUE);
int maxLanes = 0; // Track which lane positions are currently "open" (started but parent not yet seen)
for (PlotCommit<PlotLane> pc : plotCommitList) Set<Integer> openLanes = new LinkedHashSet<>();
{
if (pc.getLane() != null && pc.getLane().getPosition() + 1 > maxLanes)
maxLanes = pc.getLane().getPosition() + 1;
}
List<CommitInfo> commits = new ArrayList<>(); List<CommitInfo> commits = new ArrayList<>();
for (PlotCommit<PlotLane> pc : plotCommitList) for (PlotCommit<PlotLane> pc : plotCommitList)
{ {
String graphLine = buildGraphLine(pc, maxLanes); int lane = pc.getLane() != null ? pc.getLane().getPosition() : 0;
openLanes.add(lane); // ensure this lane is marked active
String graphLine = buildGraphLine(pc, new LinkedHashSet<>(openLanes));
// Advance lane state: close this lane, open parent lanes
openLanes.remove(lane);
for (RevCommit parent : pc.getParents())
{
if (parent instanceof PlotCommit<?> pp && pp.getLane() != null)
openLanes.add(pp.getLane().getPosition());
}
List<String> parents = Arrays.stream(pc.getParents()) List<String> parents = Arrays.stream(pc.getParents())
.map(p -> p.getId().abbreviate(7).name()) .map(p -> p.getId().abbreviate(7).name())
.toList(); .toList();
@@ -330,27 +392,73 @@ public class GitService
} }
} }
private String buildGraphLine(PlotCommit<PlotLane> pc, int maxLanes) private String buildGraphLine(PlotCommit<PlotLane> pc, Set<Integer> activeLanes)
{ {
int lanePos = pc.getLane() != null ? pc.getLane().getPosition() : 0; int commitLane = pc.getLane() != null ? pc.getLane().getPosition() : 0;
int width = Math.max(maxLanes, lanePos + 1);
char[] line = new char[width];
Arrays.fill(line, ' ');
// Mark passing-through lanes // Find the leftmost parent lane that is to the left of this commit's lane.
for (int i = 0; i < pc.getChildCount(); i++) // Dashes are drawn from that lane to this commit to show convergence.
int mergeFromLane = commitLane;
for (RevCommit parent : pc.getParents())
{ {
PlotCommit child = (PlotCommit) pc.getChild(i); if (parent instanceof PlotCommit<?> pp && pp.getLane() != null)
if (child.getLane() != null)
{ {
int childPos = child.getLane().getPosition(); int pLane = pp.getLane().getPosition();
if (childPos < width) if (pLane < commitLane)
line[childPos] = '|'; mergeFromLane = Math.min(mergeFromLane, pLane);
} }
} }
line[lanePos] = '*'; int width = activeLanes.stream().mapToInt(Integer::intValue).max().orElse(0) + 1;
return new String(line);
StringBuilder sb = new StringBuilder();
for (int i = 0; i < width; i++)
{
if (i == commitLane)
sb.append('*');
else if (activeLanes.contains(i))
sb.append('|');
else
sb.append(' ');
if (i < width - 1)
{
// Draw '-' between mergeFromLane and commitLane to show branch convergence
if (i >= mergeFromLane && i < commitLane)
sb.append('-');
else
sb.append(' ');
}
}
return sb.toString().stripTrailing();
}
public CommitInfo getCommitInfo(String name, String commitHash) throws IOException
{
try (Git git = openRepository(name))
{
Repository repo = git.getRepository();
ObjectId commitId = repo.resolve(commitHash);
try (var walk = new org.eclipse.jgit.revwalk.RevWalk(repo))
{
RevCommit commit = walk.parseCommit(commitId);
DateTimeFormatter fmt = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm")
.withZone(ZoneId.systemDefault());
List<String> parents = Arrays.stream(commit.getParents())
.map(p -> p.getId().abbreviate(7).name())
.toList();
return new CommitInfo(
commit.getId().getName(),
commit.getId().abbreviate(7).name(),
commit.getFullMessage(),
commit.getAuthorIdent().getName(),
fmt.format(Instant.ofEpochSecond(commit.getCommitTime())),
parents,
""
);
}
}
} }
public List<DiffInfo> getCommitDiff(String name, String commitHash) throws IOException public List<DiffInfo> getCommitDiff(String name, String commitHash) throws IOException
@@ -412,13 +520,15 @@ public class GitService
if (dirPath != null && !dirPath.isEmpty()) if (dirPath != null && !dirPath.isEmpty())
{ {
tw.setFilter(PathFilter.create(dirPath)); tw.setFilter(PathFilter.create(dirPath));
// Walk into the directory // Walk into the directory, entering intermediate subtrees as needed
while (tw.next()) while (tw.next())
{ {
if (tw.isSubtree() && tw.getPathString().equals(dirPath)) if (tw.isSubtree())
{ {
String path = tw.getPathString();
tw.enterSubtree(); tw.enterSubtree();
break; if (path.equals(dirPath))
break;
} }
} }
} }
@@ -436,6 +546,14 @@ public class GitService
} }
public String getFileContentAtCommit(String name, String commitHash, String filePath) throws IOException public String getFileContentAtCommit(String name, String commitHash, String filePath) throws IOException
{
byte[] bytes = getRawFileAtCommit(name, commitHash, filePath);
if (bytes == null)
return null;
return new String(bytes, StandardCharsets.UTF_8);
}
public byte[] getRawFileAtCommit(String name, String commitHash, String filePath) throws IOException
{ {
try (Git git = openRepository(name)) try (Git git = openRepository(name))
{ {
@@ -451,7 +569,7 @@ public class GitService
if (tw == null) if (tw == null)
return null; return null;
ObjectLoader loader = repo.open(tw.getObjectId(0)); ObjectLoader loader = repo.open(tw.getObjectId(0));
return new String(loader.getBytes(), StandardCharsets.UTF_8); return loader.getBytes();
} }
} }
} }
@@ -480,11 +598,41 @@ public class GitService
} }
} }
public int[] getAheadBehind(String name) throws IOException
{
try (Git git = openRepository(name))
{
String branch = git.getRepository().getBranch();
org.eclipse.jgit.lib.BranchTrackingStatus status =
org.eclipse.jgit.lib.BranchTrackingStatus.of(git.getRepository(), branch);
if (status == null)
return null;
return new int[]{status.getAheadCount(), status.getBehindCount()};
}
}
public void push(String name) throws IOException, GitAPIException public void push(String name) throws IOException, GitAPIException
{ {
try (Git git = openRepository(name)) try (Git git = openRepository(name))
{ {
git.push().call(); var cmd = git.push();
if (properties.getUsername() != null)
cmd.setCredentialsProvider(new org.eclipse.jgit.transport.UsernamePasswordCredentialsProvider(
properties.getUsername(), properties.getPassword()));
Iterable<org.eclipse.jgit.transport.PushResult> results = cmd.call();
for (var result : results)
{
for (var update : result.getRemoteUpdates())
{
var status = update.getStatus();
if (status != org.eclipse.jgit.transport.RemoteRefUpdate.Status.OK
&& status != org.eclipse.jgit.transport.RemoteRefUpdate.Status.UP_TO_DATE)
{
throw new IOException("Push failed: " + update.getRemoteName() + " " + status
+ (update.getMessage() != null ? " - " + update.getMessage() : ""));
}
}
}
} }
} }
@@ -492,7 +640,15 @@ public class GitService
{ {
try (Git git = openRepository(name)) try (Git git = openRepository(name))
{ {
git.pull().call(); var cmd = git.pull();
if (properties.getUsername() != null)
cmd.setCredentialsProvider(new org.eclipse.jgit.transport.UsernamePasswordCredentialsProvider(
properties.getUsername(), properties.getPassword()));
var result = cmd.call();
if (!result.isSuccessful())
{
throw new IOException("Pull failed: " + result.getMergeResult().getMergeStatus());
}
} }
} }
@@ -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=
+5 -2
View File
@@ -9,7 +9,7 @@
<p> <p>
<a th:href="@{/repo/{name}/commits(name=${name})}">&lt; Back to commits</a> <a th:href="@{/repo/{name}/commits(name=${name})}">&lt; Back to commits</a>
| |
<a th:href="@{/repo/{name}/tree/{hash}(name=${name}, hash=${hash})}">&lt; Back to tree</a> <a th:href="@{/repo/{name}/tree/{hash}(name=${name}, hash=${hash}, path=${parentPath})}">&lt; Parent directory</a>
</p> </p>
<table border="0" cellpadding="4" cellspacing="0"> <table border="0" cellpadding="4" cellspacing="0">
@@ -25,7 +25,10 @@
</table> </table>
<h3>Content</h3> <h3>Content</h3>
<pre th:text="${content}">File content here</pre> <div th:if="${isImage}">
<img th:src="@{/repo/{name}/raw/{hash}/{filePath}(name=${name}, hash=${hash}, filePath=${filePath})}" alt="" border="0">
</div>
<pre th:unless="${isImage}" th:text="${content}">File content here</pre>
</body> </body>
</html> </html>
+26 -7
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>
+14
View File
@@ -8,6 +8,20 @@
<p><a th:href="@{/repo/{name}/commits(name=${name})}">&lt; Back to commits</a></p> <p><a th:href="@{/repo/{name}/commits(name=${name})}">&lt; Back to commits</a></p>
<table border="0" cellpadding="4" cellspacing="0">
<tr>
<td><b>Author:</b></td>
<td th:text="${commitInfo.author}"></td>
</tr>
<tr>
<td><b>Date:</b></td>
<td th:text="${commitInfo.date}"></td>
</tr>
</table>
<h3 th:text="${commitTitle}"></h3>
<pre th:if="${!commitBody.isEmpty()}" th:text="${commitBody}"></pre>
<table border="0" cellpadding="4" cellspacing="0"> <table border="0" cellpadding="4" cellspacing="0">
<tr> <tr>
<td> <td>
@@ -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>
+7 -6
View File
@@ -6,14 +6,15 @@
<body> <body>
<h2>Browse <span th:text="${shortHash}"></span></h2> <h2>Browse <span th:text="${shortHash}"></span></h2>
<p><a th:href="@{/repo/{name}/commits(name=${name})}">&lt; Back to commits</a></p> <p>
<a th:href="@{/repo/{name}/commits(name=${name})}">&lt; Back to commits</a>
<p th:if="${!path.isEmpty()}"> <span th:if="${!path.isEmpty()}">
Path: <b th:text="${path}"></b> | <a th:href="@{/repo/{name}/tree/{hash}(name=${name}, hash=${hash}, path=${parentPath})}">&lt; Parent directory</a>
<br> </span>
<a th:href="@{/repo/{name}/tree/{hash}(name=${name}, hash=${hash}, path=${path.contains('/') ? path.substring(0, path.lastIndexOf('/')) : ''})}">&lt; Parent directory</a>
</p> </p>
<p th:if="${!path.isEmpty()}">Path: <b th:text="${path}"></b></p>
<table border="1" cellpadding="4" cellspacing="0"> <table border="1" cellpadding="4" cellspacing="0">
<tr> <tr>
<th>Type</th> <th>Type</th>
@@ -9,8 +9,10 @@ import org.springframework.test.web.servlet.MockMvc;
import java.util.List; import java.util.List;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when; import static org.mockito.Mockito.when;
import static org.mockito.ArgumentMatchers.any;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
@@ -139,6 +141,18 @@ class RepoControllerTest
verify(gitService).stageFiles("myrepo", List.of("a.txt", "b.txt")); verify(gitService).stageFiles("myrepo", List.of("a.txt", "b.txt"));
} }
@Test
void rollbackRedirectsToChanges() throws Exception
{
mockMvc.perform(post("/repo/myrepo/stage")
.param("files", "a.txt")
.param("action", "Rollback Selected"))
.andExpect(status().is3xxRedirection())
.andExpect(redirectedUrl("/repo/myrepo/changes"));
verify(gitService).rollbackFiles("myrepo", List.of("a.txt"));
}
@Test @Test
void unstageRedirectsToChanges() throws Exception void unstageRedirectsToChanges() throws Exception
{ {
@@ -232,6 +246,9 @@ class RepoControllerTest
@Test @Test
void commitDetailShowsDiff() throws Exception void commitDetailShowsDiff() throws Exception
{ {
when(gitService.getCommitInfo("myrepo", "abc1234")).thenReturn(
new be.seeseepuff.webgit.model.CommitInfo("abc1234abc1234", "abc1234", "Fix bug\n\nDetails here", "Alice", "2024-01-01 12:00", List.of(), "")
);
when(gitService.getCommitDiff("myrepo", "abc1234")).thenReturn(List.of( when(gitService.getCommitDiff("myrepo", "abc1234")).thenReturn(List.of(
new be.seeseepuff.webgit.model.DiffInfo("ADD", "/dev/null", "file.txt", "+hello") new be.seeseepuff.webgit.model.DiffInfo("ADD", "/dev/null", "file.txt", "+hello")
)); ));
@@ -240,6 +257,7 @@ class RepoControllerTest
.andExpect(status().isOk()) .andExpect(status().isOk())
.andExpect(view().name("commit")) .andExpect(view().name("commit"))
.andExpect(model().attribute("hash", "abc1234")) .andExpect(model().attribute("hash", "abc1234"))
.andExpect(content().string(org.hamcrest.Matchers.containsString("Fix bug")))
.andExpect(content().string(org.hamcrest.Matchers.containsString("file.txt"))); .andExpect(content().string(org.hamcrest.Matchers.containsString("file.txt")));
} }
@@ -267,6 +285,37 @@ class RepoControllerTest
.andExpect(content().string(org.hamcrest.Matchers.containsString("# Hello"))); .andExpect(content().string(org.hamcrest.Matchers.containsString("# Hello")));
} }
@Test
void blobShowsImageTag() throws Exception
{
mockMvc.perform(get("/repo/myrepo/blob/abc1234/photo.png"))
.andExpect(status().isOk())
.andExpect(view().name("blob"))
.andExpect(content().string(org.hamcrest.Matchers.containsString("/repo/myrepo/raw/abc1234/photo.png")));
verify(gitService, never()).getFileContentAtCommit(any(), any(), any());
}
@Test
void rawFileServesBytes() throws Exception
{
byte[] imageBytes = new byte[]{(byte)0x89, 0x50, 0x4E, 0x47};
when(gitService.getRawFileAtCommit("myrepo", "abc1234", "photo.png")).thenReturn(imageBytes);
mockMvc.perform(get("/repo/myrepo/raw/abc1234/photo.png"))
.andExpect(status().isOk())
.andExpect(content().contentType("image/png"))
.andExpect(content().bytes(imageBytes));
}
@Test
void rawFileReturns404WhenNotFound() throws Exception
{
when(gitService.getRawFileAtCommit("myrepo", "abc1234", "missing.png")).thenReturn(null);
mockMvc.perform(get("/repo/myrepo/raw/abc1234/missing.png"))
.andExpect(status().isNotFound());
}
@Test @Test
void checkoutCommitRedirectsToCommits() throws Exception void checkoutCommitRedirectsToCommits() throws Exception
{ {
@@ -290,4 +339,90 @@ class RepoControllerTest
verify(gitService).checkoutFilesFromCommit("myrepo", "abc1234", List.of("a.txt", "b.txt")); verify(gitService).checkoutFilesFromCommit("myrepo", "abc1234", List.of("a.txt", "b.txt"));
} }
@Test
void fileDiffShowsDiff() throws Exception
{
when(gitService.getWorkingTreeDiff("myrepo", "src/main.txt")).thenReturn("diff --git a/src/main.txt\n+hello");
mockMvc.perform(get("/repo/myrepo/diff/src/main.txt"))
.andExpect(status().isOk())
.andExpect(view().name("file-diff"))
.andExpect(model().attribute("filePath", "src/main.txt"))
.andExpect(content().string(org.hamcrest.Matchers.containsString("+hello")));
}
@Test
void changesPageShowsAheadBehind() throws Exception
{
when(gitService.getModifiedFiles("myrepo")).thenReturn(List.of());
when(gitService.getStagedFiles("myrepo")).thenReturn(List.of());
when(gitService.getAheadBehind("myrepo")).thenReturn(new int[]{3, 1});
mockMvc.perform(get("/repo/myrepo/changes"))
.andExpect(status().isOk())
.andExpect(model().attribute("commitsAhead", 3))
.andExpect(model().attribute("commitsBehind", 1));
}
@Test
void changesPageHidesAheadBehindWhenNoTracking() throws Exception
{
when(gitService.getModifiedFiles("myrepo")).thenReturn(List.of());
when(gitService.getStagedFiles("myrepo")).thenReturn(List.of());
when(gitService.getAheadBehind("myrepo")).thenReturn(null);
mockMvc.perform(get("/repo/myrepo/changes"))
.andExpect(status().isOk())
.andExpect(model().attributeDoesNotExist("commitsAhead"))
.andExpect(model().attributeDoesNotExist("commitsBehind"));
}
@Test
void pushRedirectsToChangesWhenRequested() throws Exception
{
mockMvc.perform(post("/repo/myrepo/push")
.param("redirectTo", "changes"))
.andExpect(status().is3xxRedirection())
.andExpect(redirectedUrl("/repo/myrepo/changes"));
verify(gitService).push("myrepo");
}
@Test
void pullRedirectsToChangesWhenRequested() throws Exception
{
mockMvc.perform(post("/repo/myrepo/pull")
.param("redirectTo", "changes"))
.andExpect(status().is3xxRedirection())
.andExpect(redirectedUrl("/repo/myrepo/changes"));
verify(gitService).pull("myrepo");
}
@Test
void stageWithSelectAllStagesAllModifiedFiles() throws Exception
{
when(gitService.getModifiedFiles("myrepo")).thenReturn(List.of("a.txt", "b.txt", "c.txt"));
mockMvc.perform(post("/repo/myrepo/stage")
.param("selectAll", "on"))
.andExpect(status().is3xxRedirection())
.andExpect(redirectedUrl("/repo/myrepo/changes"));
verify(gitService).stageFiles("myrepo", List.of("a.txt", "b.txt", "c.txt"));
}
@Test
void unstageWithSelectAllUnstagesAllStagedFiles() throws Exception
{
when(gitService.getStagedFiles("myrepo")).thenReturn(List.of("a.txt", "b.txt"));
mockMvc.perform(post("/repo/myrepo/unstage")
.param("selectAll", "on"))
.andExpect(status().is3xxRedirection())
.andExpect(redirectedUrl("/repo/myrepo/changes"));
verify(gitService).unstageFiles("myrepo", List.of("a.txt", "b.txt"));
}
} }
@@ -349,6 +349,66 @@ class GitServiceTest
assertNotNull(commits.getFirst().graphLine()); assertNotNull(commits.getFirst().graphLine());
} }
@Test
void getCommitInfoReturnsDetails() throws GitAPIException, IOException
{
gitService.cloneRepository(bareRemote.toUri().toString(), "myrepo");
var commits = gitService.listCommits("myrepo");
var info = gitService.getCommitInfo("myrepo", commits.getFirst().hash());
assertNotNull(info);
assertTrue(info.message().contains("Initial commit"));
assertNotNull(info.author());
assertNotNull(info.date());
}
@Test
void listCommitsGraphLineShowsBranchingCorrectly() throws GitAPIException, IOException
{
// Create repo with two branches from initial commit: main (C) and feature (B)
gitService.cloneRepository(bareRemote.toUri().toString(), "myrepo");
// Make commit C on main
Files.writeString(worktreePath.resolve("myrepo/main.txt"), "main");
gitService.stageFiles("myrepo", List.of("main.txt"));
gitService.commit("myrepo", "C");
// Create feature branch from initial commit (A) and make commit B
gitService.createAndCheckoutBranch("myrepo", "feature");
// Rewind feature to initial commit
try (Git git = Git.open(worktreePath.resolve("myrepo").toFile()))
{
var initialCommit = gitService.listCommits("myrepo").stream()
.filter(c -> c.message().equals("Initial commit"))
.findFirst()
.orElseThrow();
git.reset().setMode(org.eclipse.jgit.api.ResetCommand.ResetType.HARD)
.setRef(initialCommit.hash()).call();
}
Files.writeString(worktreePath.resolve("myrepo/feature.txt"), "feature");
gitService.stageFiles("myrepo", List.of("feature.txt"));
gitService.commit("myrepo", "B");
// Switch back to main
gitService.checkoutBranch("myrepo", "master".equals(gitService.listBranches("myrepo").stream()
.filter(b -> b.equals("master") || b.equals("main")).findFirst().orElse("master"))
? "master" : "main");
var commits = gitService.listCommits("myrepo");
// Lane-0 commits should have graph lines starting with '*'
var lane0Commits = commits.stream()
.filter(c -> c.graphLine().startsWith("*"))
.toList();
// Lane-1 commit (B) should have '|' then '-' then '*'
var lane1Commits = commits.stream()
.filter(c -> c.graphLine().startsWith("|-"))
.toList();
assertFalse(lane0Commits.isEmpty(), "Expected commits on lane 0");
assertFalse(lane1Commits.isEmpty(), "Expected branching commit with |-* pattern");
}
@Test @Test
void getCommitDiffReturnsDiffs() throws GitAPIException, IOException void getCommitDiffReturnsDiffs() throws GitAPIException, IOException
{ {
@@ -373,6 +433,23 @@ class GitServiceTest
assertTrue(files.stream().anyMatch(f -> f.path().equals("README.md"))); assertTrue(files.stream().anyMatch(f -> f.path().equals("README.md")));
} }
@Test
void listFilesAtCommitReturnsNestedDirectoryContents() throws GitAPIException, IOException
{
gitService.cloneRepository(bareRemote.toUri().toString(), "myrepo");
// Create a file two levels deep: src/main/Hello.txt
Path srcMain = worktreePath.resolve("myrepo/src/main");
Files.createDirectories(srcMain);
Files.writeString(srcMain.resolve("Hello.txt"), "hello");
gitService.stageFiles("myrepo", List.of("src/main/Hello.txt"));
gitService.commit("myrepo", "Add nested file");
var commits = gitService.listCommits("myrepo");
var files = gitService.listFilesAtCommit("myrepo", commits.getFirst().hash(), "src/main");
assertFalse(files.isEmpty());
assertTrue(files.stream().anyMatch(f -> f.path().equals("src/main/Hello.txt")));
}
@Test @Test
void getFileContentAtCommitReturnsContent() throws GitAPIException, IOException void getFileContentAtCommitReturnsContent() throws GitAPIException, IOException
{ {
@@ -383,6 +460,27 @@ class GitServiceTest
assertEquals("# Test", content); assertEquals("# Test", content);
} }
@Test
void getRawFileAtCommitReturnsBytes() throws GitAPIException, IOException
{
gitService.cloneRepository(bareRemote.toUri().toString(), "myrepo");
var commits = gitService.listCommits("myrepo");
byte[] bytes = gitService.getRawFileAtCommit("myrepo", commits.getFirst().hash(), "README.md");
assertNotNull(bytes);
assertEquals("# Test", new String(bytes, java.nio.charset.StandardCharsets.UTF_8));
}
@Test
void getRawFileAtCommitReturnsNullForMissingFile() throws GitAPIException, IOException
{
gitService.cloneRepository(bareRemote.toUri().toString(), "myrepo");
var commits = gitService.listCommits("myrepo");
byte[] bytes = gitService.getRawFileAtCommit("myrepo", commits.getFirst().hash(), "nonexistent.png");
assertNull(bytes);
}
@Test @Test
void getFileContentAtCommitReturnsNullForMissingFile() throws GitAPIException, IOException void getFileContentAtCommitReturnsNullForMissingFile() throws GitAPIException, IOException
{ {
@@ -419,4 +517,116 @@ class GitServiceTest
assertEquals("# Test", Files.readString(readme)); assertEquals("# Test", Files.readString(readme));
} }
@Test
void getWorkingTreeDiffShowsUnstagedChanges() throws GitAPIException, IOException
{
gitService.cloneRepository(bareRemote.toUri().toString(), "myrepo");
Files.writeString(worktreePath.resolve("myrepo").resolve("README.md"), "modified content");
String diff = gitService.getWorkingTreeDiff("myrepo", "README.md");
assertFalse(diff.isEmpty());
assertTrue(diff.contains("README.md"));
}
@Test
void getWorkingTreeDiffShowsStagedChanges() throws GitAPIException, IOException
{
gitService.cloneRepository(bareRemote.toUri().toString(), "myrepo");
Files.writeString(worktreePath.resolve("myrepo").resolve("README.md"), "staged content");
gitService.stageFiles("myrepo", List.of("README.md"));
String diff = gitService.getWorkingTreeDiff("myrepo", "README.md");
assertFalse(diff.isEmpty());
assertTrue(diff.contains("README.md"));
}
@Test
void getWorkingTreeDiffReturnsEmptyForUnchangedFile() throws GitAPIException, IOException
{
gitService.cloneRepository(bareRemote.toUri().toString(), "myrepo");
String diff = gitService.getWorkingTreeDiff("myrepo", "README.md");
assertTrue(diff.isEmpty());
}
@Test
void rollbackFilesRestoresContent() throws GitAPIException, IOException
{
gitService.cloneRepository(bareRemote.toUri().toString(), "myrepo");
Path readme = worktreePath.resolve("myrepo").resolve("README.md");
Files.writeString(readme, "modified");
gitService.rollbackFiles("myrepo", List.of("README.md"));
assertEquals("# Test", Files.readString(readme));
}
@Test
void getAheadBehindReturnsZeroWhenUpToDate() throws GitAPIException, IOException
{
gitService.cloneRepository(bareRemote.toUri().toString(), "myrepo");
int[] result = gitService.getAheadBehind("myrepo");
assertNotNull(result);
assertEquals(0, result[0]);
assertEquals(0, result[1]);
}
@Test
void getAheadBehindReturnsAheadCount() throws GitAPIException, IOException
{
gitService.cloneRepository(bareRemote.toUri().toString(), "myrepo");
Files.writeString(worktreePath.resolve("myrepo/newfile.txt"), "hello");
gitService.stageFiles("myrepo", List.of("newfile.txt"));
gitService.commit("myrepo", "Local commit");
int[] result = gitService.getAheadBehind("myrepo");
assertNotNull(result);
assertEquals(1, result[0]);
assertEquals(0, result[1]);
}
@Test
void getAheadBehindReturnsBehindCount() throws GitAPIException, IOException
{
gitService.cloneRepository(bareRemote.toUri().toString(), "myrepo");
// Push a commit to the remote via a second clone
Path tmpWork = tempDir.resolve("tmp-work2");
try (Git tmp = Git.cloneRepository()
.setURI(bareRemote.toUri().toString())
.setDirectory(tmpWork.toFile())
.call())
{
Files.writeString(tmpWork.resolve("remote-file.txt"), "remote");
tmp.add().addFilepattern("remote-file.txt").call();
tmp.commit().setMessage("Remote commit").call();
tmp.push().call();
}
// Fetch so our repo knows about the remote commit
try (Git git = Git.open(worktreePath.resolve("myrepo").toFile()))
{
git.fetch().call();
}
int[] result = gitService.getAheadBehind("myrepo");
assertNotNull(result);
assertEquals(0, result[0]);
assertEquals(1, result[1]);
}
@Test
void pushFailsWithBadRemote() throws GitAPIException, IOException
{
gitService.cloneRepository(bareRemote.toUri().toString(), "myrepo");
gitService.updateRemoteUrl("myrepo", "origin", "https://invalid.example.com/repo.git");
Files.writeString(worktreePath.resolve("myrepo/newfile.txt"), "hello");
gitService.stageFiles("myrepo", List.of("newfile.txt"));
gitService.commit("myrepo", "Local commit");
assertThrows(Exception.class, () -> gitService.push("myrepo"));
}
} }