Compare commits
12 Commits
e079eed52d
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 4077c1b28e | |||
| e6f6e2466b | |||
| 5be1b1cc29 | |||
| 36ecd019a8 | |||
| 472db2dd96 | |||
| b5097685c7 | |||
| 6a532322c4 | |||
| 52fe455c76 | |||
| a27c9fba00 | |||
| 9b1668def9 | |||
| ec79a0c5cf | |||
| 2dcacdbe8d |
@@ -3,6 +3,7 @@ on:
|
||||
push:
|
||||
branches:
|
||||
- '*'
|
||||
- '!master'
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
@@ -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 .
|
||||
|
||||
@@ -31,5 +34,5 @@ jobs:
|
||||
- name: Push Container
|
||||
run: docker push gitea.seeseepuff.be/seeseemelk/webgit:latest
|
||||
|
||||
- name: Push Container
|
||||
run: docker push gitea.seeseepuff.be/seeseemelk/webgit:latest
|
||||
- name: Trigger Watchtower
|
||||
uses: https://gitea.seeseepuff.be/actions/watchtower@v1
|
||||
|
||||
26
.github/copilot-instructions.md
vendored
Normal file
26
.github/copilot-instructions.md
vendored
Normal file
@@ -0,0 +1,26 @@
|
||||
# Copilot Instructions
|
||||
|
||||
## System Requirements
|
||||
|
||||
This project targets **retro systems** (Windows 3.11, Windows 98, FreeDOS).
|
||||
The UI must work in period-appropriate browsers:
|
||||
|
||||
- **No JavaScript** — all interactivity must be server-side.
|
||||
- **No CSS** — use HTML attributes (`border`, `cellpadding`, `cellspacing`) for layout.
|
||||
- **Use table-based layouts** to arrange elements side by side.
|
||||
- Keep HTML simple: plain `<form>`, `<table>`, `<input>`, `<a>` elements only.
|
||||
|
||||
## Testing
|
||||
|
||||
- **All new functionality must have unit tests.**
|
||||
- Service-layer tests go in `src/test/java/.../service/GitServiceTest.java` and use
|
||||
real JGit repositories created in `@TempDir` directories.
|
||||
- Controller tests go in `src/test/java/.../controller/RepoControllerTest.java` and
|
||||
use `@WebMvcTest` with `@MockitoBean` for the service layer.
|
||||
- Run `./gradlew test` to verify all tests pass before considering a change complete.
|
||||
|
||||
## Tech Stack
|
||||
|
||||
- Java 25, Spring Boot 4.0, Thymeleaf, Gradle
|
||||
- JGit for all Git operations (no shelling out to `git`)
|
||||
- Lombok for boilerplate reduction (`@Getter`, `@Setter`, `@RequiredArgsConstructor`)
|
||||
@@ -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:
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -16,4 +16,6 @@ public class WebgitProperties
|
||||
private Path worktreePath;
|
||||
private Path gitDirPath;
|
||||
private Integer telnetPort;
|
||||
private String username;
|
||||
private String password;
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
@@ -193,20 +199,26 @@ public class RepoController
|
||||
}
|
||||
|
||||
@PostMapping("/repo/{name}/stage")
|
||||
public String stage(@PathVariable String name, @RequestParam List<String> files,
|
||||
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
|
||||
{
|
||||
List<String> filesToProcess = (selectAll != null) ? gitService.getModifiedFiles(name) : (files != null ? files : List.of());
|
||||
if ("Rollback Selected".equals(action))
|
||||
gitService.rollbackFiles(name, files);
|
||||
gitService.rollbackFiles(name, filesToProcess);
|
||||
else
|
||||
gitService.stageFiles(name, files);
|
||||
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";
|
||||
}
|
||||
|
||||
@@ -218,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")
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -6,12 +6,29 @@
|
||||
<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)}">
|
||||
<table border="1" cellpadding="4" cellspacing="0">
|
||||
<tr>
|
||||
<th></th>
|
||||
<th><input type="checkbox" name="selectAll"></th>
|
||||
<th>File</th>
|
||||
</tr>
|
||||
<tr th:each="file : ${modifiedFiles}">
|
||||
@@ -29,7 +46,7 @@
|
||||
<form method="post" th:action="@{/repo/{name}/unstage(name=${name})}" th:if="${!#lists.isEmpty(stagedFiles)}">
|
||||
<table border="1" cellpadding="4" cellspacing="0">
|
||||
<tr>
|
||||
<th></th>
|
||||
<th><input type="checkbox" name="selectAll"></th>
|
||||
<th>File</th>
|
||||
</tr>
|
||||
<tr th:each="file : ${stagedFiles}">
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user