Compare commits

..

8 Commits

Author SHA1 Message Date
4077c1b28e Log test results
All checks were successful
Deploy / build (push) Successful in 1m41s
2026-02-27 22:02:13 +01:00
e6f6e2466b Add test step
All checks were successful
Deploy / build (push) Successful in 1m4s
2026-02-27 21:56:51 +01:00
5be1b1cc29 Throw exception on authentication issues 2026-02-27 21:54:30 +01:00
36ecd019a8 Add copilot instructions 2026-02-27 21:53:56 +01:00
472db2dd96 Incrase spacing in staging view
All checks were successful
Deploy / build (push) Successful in 50s
2026-02-27 19:31:10 +01:00
b5097685c7 Put push/pull buttons on one line
All checks were successful
Deploy / build (push) Successful in 49s
2026-02-27 19:29:08 +01:00
6a532322c4 Add credential settings
All checks were successful
Deploy / build (push) Successful in 55s
2026-02-27 19:22:38 +01:00
52fe455c76 Add ahead/behind indicator and push/pull buttons 2026-02-27 19:22:03 +01:00
11 changed files with 255 additions and 6 deletions

View File

@@ -20,6 +20,9 @@ jobs:
- 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 .

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

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

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:

View File

@@ -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 {

View File

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

View File

@@ -57,6 +57,12 @@ public class RepoController
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";
}
@@ -224,17 +230,19 @@ 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 + "/remote";
return "redirect:/repo/" + name + "/" + redirectTo;
}
@PostMapping("/repo/{name}/update-remote")

View File

@@ -598,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
{
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() : ""));
}
}
}
}
}
@@ -610,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());
}
}
}

View File

@@ -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=

View File

@@ -6,6 +6,23 @@
<body>
<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)}">

View File

@@ -351,4 +351,78 @@ class RepoControllerTest
.andExpect(model().attribute("filePath", "src/main.txt"))
.andExpect(content().string(org.hamcrest.Matchers.containsString("+hello")));
}
@Test
void changesPageShowsAheadBehind() throws Exception
{
when(gitService.getModifiedFiles("myrepo")).thenReturn(List.of());
when(gitService.getStagedFiles("myrepo")).thenReturn(List.of());
when(gitService.getAheadBehind("myrepo")).thenReturn(new int[]{3, 1});
mockMvc.perform(get("/repo/myrepo/changes"))
.andExpect(status().isOk())
.andExpect(model().attribute("commitsAhead", 3))
.andExpect(model().attribute("commitsBehind", 1));
}
@Test
void changesPageHidesAheadBehindWhenNoTracking() throws Exception
{
when(gitService.getModifiedFiles("myrepo")).thenReturn(List.of());
when(gitService.getStagedFiles("myrepo")).thenReturn(List.of());
when(gitService.getAheadBehind("myrepo")).thenReturn(null);
mockMvc.perform(get("/repo/myrepo/changes"))
.andExpect(status().isOk())
.andExpect(model().attributeDoesNotExist("commitsAhead"))
.andExpect(model().attributeDoesNotExist("commitsBehind"));
}
@Test
void pushRedirectsToChangesWhenRequested() throws Exception
{
mockMvc.perform(post("/repo/myrepo/push")
.param("redirectTo", "changes"))
.andExpect(status().is3xxRedirection())
.andExpect(redirectedUrl("/repo/myrepo/changes"));
verify(gitService).push("myrepo");
}
@Test
void pullRedirectsToChangesWhenRequested() throws Exception
{
mockMvc.perform(post("/repo/myrepo/pull")
.param("redirectTo", "changes"))
.andExpect(status().is3xxRedirection())
.andExpect(redirectedUrl("/repo/myrepo/changes"));
verify(gitService).pull("myrepo");
}
@Test
void stageWithSelectAllStagesAllModifiedFiles() throws Exception
{
when(gitService.getModifiedFiles("myrepo")).thenReturn(List.of("a.txt", "b.txt", "c.txt"));
mockMvc.perform(post("/repo/myrepo/stage")
.param("selectAll", "on"))
.andExpect(status().is3xxRedirection())
.andExpect(redirectedUrl("/repo/myrepo/changes"));
verify(gitService).stageFiles("myrepo", List.of("a.txt", "b.txt", "c.txt"));
}
@Test
void unstageWithSelectAllUnstagesAllStagedFiles() throws Exception
{
when(gitService.getStagedFiles("myrepo")).thenReturn(List.of("a.txt", "b.txt"));
mockMvc.perform(post("/repo/myrepo/unstage")
.param("selectAll", "on"))
.andExpect(status().is3xxRedirection())
.andExpect(redirectedUrl("/repo/myrepo/changes"));
verify(gitService).unstageFiles("myrepo", List.of("a.txt", "b.txt"));
}
}

View File

@@ -561,4 +561,72 @@ class GitServiceTest
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"));
}
}