Compare commits

...

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

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

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

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

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

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-02-27 09:28:43 +01:00
seeseemelk 005e0c7d23 Make staging (changes) the default repo view
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-02-27 09:23:59 +01:00
seeseemelk be130582fc Reorder sidebar: Staging above Branches, rename Changes to Staging
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-02-27 09:21:34 +01:00
seeseemelk ba3bf697f0 Move '(current)' label to the checkout button column
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-02-27 09:20:24 +01:00
seeseemelk 6efb8f1607 Replace branch dropdown with table listing each branch
Show branches in a table with a Checkout button per row.
The current branch is shown in bold with '(current)' and
has no checkout button.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-02-27 09:19:31 +01:00
32 changed files with 1592 additions and 67 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.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:
+7 -2
View File
@@ -1,7 +1,7 @@
plugins {
java
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"
}
@@ -29,7 +29,7 @@ dependencies {
implementation("org.springframework.boot:spring-boot-starter-actuator")
implementation("org.springframework.boot:spring-boot-starter-thymeleaf")
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")
developmentOnly("org.springframework.boot:spring-boot-devtools")
runtimeOnly("io.micrometer:micrometer-registry-prometheus")
@@ -43,6 +43,11 @@ dependencies {
tasks.withType<Test> {
useJUnitPlatform()
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 {
Binary file not shown.
+3 -1
View File
@@ -1,7 +1,9 @@
distributionBase=GRADLE_USER_HOME
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
retries=0
retryBackOffMs=500
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
Vendored
+1 -1
View File
@@ -57,7 +57,7 @@
# Darwin, MinGW, and NonStop.
#
# (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.
#
# You can find Gradle at https://github.com/gradle/gradle/.
Vendored
+10 -21
View File
@@ -23,8 +23,8 @@
@rem
@rem ##########################################################################
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
@rem Set local scope for the variables, and ensure extensions are enabled
setlocal EnableExtensions
set DIRNAME=%~dp0
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 location of your Java installation. 1>&2
goto fail
"%COMSPEC%" /c exit 1
:findJavaFromJavaHome
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 location of your Java installation. 1>&2
goto fail
"%COMSPEC%" /c exit 1
:execute
@rem Setup the command line
@@ -73,21 +73,10 @@ goto fail
@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
@rem End local scope for the variables with windows NT shell
if %ERRORLEVEL% equ 0 goto mainEnd
: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
:exitWithErrorLevel
@rem Use "%COMSPEC%" /c exit to allow operators to work properly in scripts
"%COMSPEC%" /c exit %ERRORLEVEL%
+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 gitDirPath;
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 lombok.RequiredArgsConstructor;
import org.eclipse.jgit.api.errors.GitAPIException;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
@@ -12,17 +14,31 @@ import org.springframework.web.bind.annotation.RequestParam;
import java.io.IOException;
import java.util.List;
import java.util.Map;
import java.util.Set;
@Controller
@RequiredArgsConstructor
public class RepoController
{
private static final Set<String> IMAGE_EXTENSIONS = Set.of("png", "jpg", "jpeg", "gif", "bmp", "webp", "svg", "ico");
private static final Map<String, String> IMAGE_MIME_TYPES = Map.of(
"png", "image/png",
"jpg", "image/jpeg",
"jpeg", "image/jpeg",
"gif", "image/gif",
"bmp", "image/bmp",
"webp", "image/webp",
"svg", "image/svg+xml",
"ico", "image/x-icon"
);
private final GitService gitService;
@GetMapping("/repo/{name}")
public String repo(@PathVariable String name)
{
return "redirect:/repo/" + name + "/branches";
return "redirect:/repo/" + name + "/changes";
}
@GetMapping("/repo/{name}/branches")
@@ -38,15 +54,36 @@ public class RepoController
public String changes(@PathVariable String name, Model model) throws IOException, GitAPIException
{
model.addAttribute("name", name);
model.addAttribute("branch", gitService.getCurrentBranch(name));
model.addAttribute("modifiedFiles", gitService.getModifiedFiles(name));
model.addAttribute("stagedFiles", gitService.getStagedFiles(name));
int[] aheadBehind = gitService.getAheadBehind(name);
if (aheadBehind != null)
{
model.addAttribute("commitsAhead", aheadBehind[0]);
model.addAttribute("commitsBehind", aheadBehind[1]);
}
return "changes";
}
@GetMapping("/repo/{name}/diff/{filePath}/**")
public String fileDiff(@PathVariable String name,
jakarta.servlet.http.HttpServletRequest request, Model model) throws IOException
{
String fullPath = request.getRequestURI();
String prefix = "/repo/" + name + "/diff/";
String filePath = fullPath.substring(prefix.length());
model.addAttribute("name", name);
model.addAttribute("filePath", filePath);
model.addAttribute("diff", gitService.getWorkingTreeDiff(name, filePath));
return "file-diff";
}
@GetMapping("/repo/{name}/remote")
public String remote(@PathVariable String name, Model model)
public String remote(@PathVariable String name, Model model) throws IOException
{
model.addAttribute("name", name);
model.addAttribute("remotes", gitService.listRemotes(name));
return "remote";
}
@@ -57,6 +94,96 @@ public class RepoController
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")
public String checkout(@PathVariable String name, @RequestParam String branch) throws IOException, GitAPIException
{
@@ -72,16 +199,26 @@ public class RepoController
}
@PostMapping("/repo/{name}/stage")
public String stage(@PathVariable String name, @RequestParam List<String> files) throws IOException, GitAPIException
public String stage(@PathVariable String name,
@RequestParam(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";
}
@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";
}
@@ -93,19 +230,35 @@ public class RepoController
}
@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);
return "redirect:/repo/" + name + "/remote";
return "redirect:/repo/" + name + "/" + redirectTo;
}
@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);
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";
}
@GetMapping("/repo/{name}/confirm-delete")
public String confirmDelete(@PathVariable String name, Model model)
{
model.addAttribute("name", name);
return "confirm-delete";
}
@PostMapping("/repo/{name}/delete")
public String delete(@PathVariable String name) throws IOException
{
@@ -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
)
{
}
@@ -0,0 +1,10 @@
package be.seeseepuff.webgit.model;
public record DiffInfo(
String changeType,
String oldPath,
String newPath,
String diff
)
{
}
@@ -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;
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 org.eclipse.jgit.api.Git;
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.Repository;
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.treewalk.TreeWalk;
import org.eclipse.jgit.treewalk.filter.PathFilter;
import org.springframework.stereotype.Service;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
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.Map;
import java.util.Set;
import java.util.stream.Stream;
@Service
@@ -109,7 +135,8 @@ public class GitService
.call()
.stream()
.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();
}
}
@@ -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
{
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
{
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
{
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
{
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))
{
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-port=2323
# Optional: credentials for push/pull (can also be set via WEBGIT_USERNAME / WEBGIT_PASSWORD env vars)
#webgit.username=
#webgit.password=
+34
View File
@@ -0,0 +1,34 @@
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<title th:text="'File - ' + ${filePath}">File</title>
</head>
<body>
<h2 th:text="${filePath}">file.txt</h2>
<p>
<a th:href="@{/repo/{name}/commits(name=${name})}">&lt; Back to commits</a>
|
<a th:href="@{/repo/{name}/tree/{hash}(name=${name}, hash=${hash}, path=${parentPath})}">&lt; Parent directory</a>
</p>
<table border="0" cellpadding="4" cellspacing="0">
<tr>
<td>
<form method="post" th:action="@{/repo/{name}/checkout-files(name=${name})}">
<input type="hidden" name="hash" th:value="${hash}">
<input type="hidden" name="files" th:value="${filePath}">
<input type="submit" value="Checkout this file">
</form>
</td>
</tr>
</table>
<h3>Content</h3>
<div th:if="${isImage}">
<img th:src="@{/repo/{name}/raw/{hash}/{filePath}(name=${name}, hash=${hash}, filePath=${filePath})}" alt="" border="0">
</div>
<pre th:unless="${isImage}" th:text="${content}">File content here</pre>
</body>
</html>
+14 -14
View File
@@ -6,26 +6,26 @@
<body>
<h2>Branches</h2>
<table border="0" cellpadding="4" cellspacing="0">
<h3>Branches</h3>
<table border="1" cellpadding="4" cellspacing="0">
<tr>
<td>Current branch:</td>
<td><b th:text="${currentBranch}"></b></td>
<th>Branch</th>
<th></th>
</tr>
</table>
<h3>Switch Branch</h3>
<form method="post" th:action="@{/repo/{name}/checkout(name=${name})}">
<table border="0" cellpadding="4" cellspacing="0">
<tr>
<tr th:each="b : ${branches}">
<td>
<select name="branch">
<option th:each="b : ${branches}" th:value="${b}" th:text="${b}" th:selected="${b == currentBranch}"></option>
</select>
<b th:if="${b == currentBranch}" th:text="${b}"></b>
<span th:unless="${b == currentBranch}" th:text="${b}"></span>
</td>
<td>
<span th:if="${b == currentBranch}">(current)</span>
<form th:unless="${b == currentBranch}" method="post" th:action="@{/repo/{name}/checkout(name=${name})}">
<input type="hidden" name="branch" th:value="${b}">
<input type="submit" value="Checkout">
</form>
</td>
<td><input type="submit" value="Checkout"></td>
</tr>
</table>
</form>
<h3>Create New Branch</h3>
<form method="post" th:action="@{/repo/{name}/new-branch(name=${name})}">
+26 -7
View File
@@ -1,25 +1,44 @@
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<title th:text="'Changes - ' + ${name}">Changes</title>
<title th:text="'Staging - ' + ${name}">Staging</title>
</head>
<body>
<h2>Changes</h2>
<h2>Staging</h2>
<p>Branch: <b th:text="${branch}"></b></p>
<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>
<form method="post" th:action="@{/repo/{name}/stage(name=${name})}" th:if="${!#lists.isEmpty(modifiedFiles)}">
<table border="1" cellpadding="4" cellspacing="0">
<tr>
<th>Stage</th>
<th><input type="checkbox" name="selectAll"></th>
<th>File</th>
</tr>
<tr th:each="file : ${modifiedFiles}">
<td><input type="checkbox" name="files" th:value="${file}"></td>
<td th:text="${file}"></td>
<td><a th:href="@{'/repo/' + ${name} + '/diff/' + ${file}}" th:text="${file}"></a></td>
</tr>
</table>
<br>
<input type="submit" value="Stage Selected">
<input type="submit" name="action" value="Stage Selected">
<input type="submit" name="action" value="Rollback Selected">
</form>
<p th:if="${#lists.isEmpty(modifiedFiles)}"><i>No modified files.</i></p>
@@ -27,12 +46,12 @@
<form method="post" th:action="@{/repo/{name}/unstage(name=${name})}" th:if="${!#lists.isEmpty(stagedFiles)}">
<table border="1" cellpadding="4" cellspacing="0">
<tr>
<th>Unstage</th>
<th><input type="checkbox" name="selectAll"></th>
<th>File</th>
</tr>
<tr th:each="file : ${stagedFiles}">
<td><input type="checkbox" name="files" th:value="${file}"></td>
<td th:text="${file}"></td>
<td><a th:href="@{'/repo/' + ${name} + '/diff/' + ${file}}" th:text="${file}"></a></td>
</tr>
</table>
<br>
+66
View File
@@ -0,0 +1,66 @@
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<title th:text="'Commit ' + ${hash}">Commit</title>
</head>
<body>
<h2>Commit <span th:text="${shortHash}"></span></h2>
<p><a th:href="@{/repo/{name}/commits(name=${name})}">&lt; Back to commits</a></p>
<table border="0" cellpadding="4" cellspacing="0">
<tr>
<td><b>Author:</b></td>
<td th:text="${commitInfo.author}"></td>
</tr>
<tr>
<td><b>Date:</b></td>
<td th:text="${commitInfo.date}"></td>
</tr>
</table>
<h3 th:text="${commitTitle}"></h3>
<pre th:if="${!commitBody.isEmpty()}" th:text="${commitBody}"></pre>
<table border="0" cellpadding="4" cellspacing="0">
<tr>
<td>
<form method="post" th:action="@{/repo/{name}/checkout-commit(name=${name})}">
<input type="hidden" name="hash" th:value="${hash}">
<input type="submit" value="Checkout this commit">
</form>
</td>
<td>
<a th:href="@{/repo/{name}/tree/{hash}(name=${name}, hash=${hash})}">Browse files</a>
</td>
</tr>
</table>
<h3>Changed Files</h3>
<form method="post" th:action="@{/repo/{name}/checkout-files(name=${name})}">
<input type="hidden" name="hash" th:value="${hash}">
<table border="1" cellpadding="4" cellspacing="0">
<tr>
<th></th>
<th>Change</th>
<th>File</th>
</tr>
<tr th:each="d : ${diffs}">
<td><input type="checkbox" name="files" th:value="${d.newPath}"></td>
<td th:text="${d.changeType}"></td>
<td th:text="${d.changeType == 'DELETE' ? d.oldPath : d.newPath}"></td>
</tr>
</table>
<br>
<input type="submit" value="Checkout selected files">
</form>
<h3>Diff</h3>
<th:block th:each="d : ${diffs}">
<h4 th:text="${d.changeType == 'DELETE' ? d.oldPath : d.newPath}"></h4>
<pre th:text="${d.diff}"></pre>
</th:block>
</body>
</html>
+41
View File
@@ -0,0 +1,41 @@
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<title th:text="'Commits - ' + ${name}">Commits</title>
</head>
<body>
<h2>Commits</h2>
<table border="1" cellpadding="4" cellspacing="0">
<tr>
<th>Graph</th>
<th>Hash</th>
<th>Message</th>
<th>Author</th>
<th>Date</th>
<th></th>
<th></th>
<th></th>
</tr>
<tr th:each="c : ${commits}">
<td><pre th:text="${c.graphLine}" style="margin:0"></pre></td>
<td><a th:href="@{/repo/{name}/commit/{hash}(name=${name}, hash=${c.hash})}" th:text="${c.shortHash}"></a></td>
<td th:text="${c.message}"></td>
<td th:text="${c.author}"></td>
<td th:text="${c.date}"></td>
<td><a th:href="@{/repo/{name}/tree/{hash}(name=${name}, hash=${c.hash})}">Browse</a></td>
<td>
<span th:if="${c.hash == headHash}">(current)</span>
<form th:unless="${c.hash == headHash}" method="post" th:action="@{/repo/{name}/checkout-commit(name=${name})}">
<input type="hidden" name="hash" th:value="${c.hash}">
<input type="submit" value="Checkout">
</form>
</td>
<td><a th:href="@{/repo/{name}/commit/{hash}(name=${name}, hash=${c.hash})}">Diff</a></td>
</tr>
</table>
<p th:if="${commits.isEmpty()}">No commits yet.</p>
</body>
</html>
@@ -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>
@@ -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>
+1 -1
View File
@@ -4,7 +4,7 @@
</head>
<frameset cols="180,*">
<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>
<body>
<p>Your browser does not support frames. <a href="/repos">Click here</a> to continue.</p>
+1 -1
View File
@@ -8,7 +8,7 @@
<h3>Danger Zone</h3>
<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">
</form>
+2 -1
View File
@@ -19,8 +19,9 @@
<th:block th:if="${selectedRepo != null}">
<hr>
<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}/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}/manage(name=${selectedRepo})}" target="content">Manage</a><br>
</th:block>
+18 -2
View File
@@ -4,10 +4,24 @@
<title th:text="'Remote - ' + ${name}">Remote</title>
</head>
<body>
<h2>Remote</h2>
<h2>Remotes</h2>
<table border="0" cellpadding="4" cellspacing="0">
<table border="1" cellpadding="4" cellspacing="0">
<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>
<form method="post" th:action="@{/repo/{name}/push(name=${name})}">
<input type="submit" value="Push">
@@ -21,5 +35,7 @@
</tr>
</table>
<p th:if="${remotes.isEmpty()}">No remotes configured.</p>
</body>
</html>
+35
View File
@@ -0,0 +1,35 @@
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<title th:text="'Browse - ' + ${shortHash}">Browse</title>
</head>
<body>
<h2>Browse <span th:text="${shortHash}"></span></h2>
<p>
<a th:href="@{/repo/{name}/commits(name=${name})}">&lt; Back to commits</a>
<span th:if="${!path.isEmpty()}">
| <a th:href="@{/repo/{name}/tree/{hash}(name=${name}, hash=${hash}, path=${parentPath})}">&lt; Parent directory</a>
</span>
</p>
<p th:if="${!path.isEmpty()}">Path: <b th:text="${path}"></b></p>
<table border="1" cellpadding="4" cellspacing="0">
<tr>
<th>Type</th>
<th>Name</th>
</tr>
<tr th:each="f : ${files}">
<td th:text="${f.type}"></td>
<td>
<a th:if="${f.type == 'tree'}" th:href="@{/repo/{name}/tree/{hash}(name=${name}, hash=${hash}, path=${f.path})}" th:text="${f.path.contains('/') ? f.path.substring(f.path.lastIndexOf('/') + 1) : f.path}"></a>
<a th:if="${f.type == 'blob'}" th:href="@{'/repo/' + ${name} + '/blob/' + ${hash} + '/' + ${f.path}}" th:text="${f.path.contains('/') ? f.path.substring(f.path.lastIndexOf('/') + 1) : f.path}"></a>
</td>
</tr>
</table>
<p th:if="${files.isEmpty()}">Empty directory.</p>
</body>
</html>
@@ -9,8 +9,10 @@ import org.springframework.test.web.servlet.MockMvc;
import java.util.List;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import static org.mockito.ArgumentMatchers.any;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
@@ -25,11 +27,11 @@ class RepoControllerTest
private GitService gitService;
@Test
void repoRedirectsToBranches() throws Exception
void repoRedirectsToChanges() throws Exception
{
mockMvc.perform(get("/repo/myrepo"))
.andExpect(status().is3xxRedirection())
.andExpect(redirectedUrl("/repo/myrepo/branches"));
.andExpect(redirectedUrl("/repo/myrepo/changes"));
}
@Test
@@ -74,12 +76,26 @@ class RepoControllerTest
}
@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"))
.andExpect(status().isOk())
.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
@@ -125,6 +141,18 @@ class RepoControllerTest
verify(gitService).stageFiles("myrepo", List.of("a.txt", "b.txt"));
}
@Test
void rollbackRedirectsToChanges() throws Exception
{
mockMvc.perform(post("/repo/myrepo/stage")
.param("files", "a.txt")
.param("action", "Rollback Selected"))
.andExpect(status().is3xxRedirection())
.andExpect(redirectedUrl("/repo/myrepo/changes"));
verify(gitService).rollbackFiles("myrepo", List.of("a.txt"));
}
@Test
void unstageRedirectsToChanges() throws Exception
{
@@ -168,6 +196,28 @@ class RepoControllerTest
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
void deleteRedirectsToRoot() throws Exception
{
@@ -177,4 +227,202 @@ class RepoControllerTest
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));
}
@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"));
}
}