Add web controllers and HTML templates

Home page lists repositories with clone form. Repo page shows
branch management, file staging, commit, push and pull controls.
Table-based layout with no JavaScript or CSS for retro browser
compatibility. Includes MockMvc integration tests.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
2026-02-26 08:43:15 +01:00
parent 239b5367a3
commit 88385d39a1
6 changed files with 433 additions and 0 deletions

View File

@@ -0,0 +1,33 @@
package be.seeseepuff.webgit.controller;
import be.seeseepuff.webgit.service.GitService;
import lombok.RequiredArgsConstructor;
import org.eclipse.jgit.api.errors.GitAPIException;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestParam;
import java.io.IOException;
@Controller
@RequiredArgsConstructor
public class HomeController
{
private final GitService gitService;
@GetMapping("/")
public String home(Model model) throws IOException
{
model.addAttribute("repositories", gitService.listRepositories());
return "home";
}
@PostMapping("/clone")
public String cloneRepo(@RequestParam String url, @RequestParam String name) throws GitAPIException, IOException
{
gitService.cloneRepository(url, name);
return "redirect:/";
}
}

View File

@@ -0,0 +1,74 @@
package be.seeseepuff.webgit.controller;
import be.seeseepuff.webgit.service.GitService;
import lombok.RequiredArgsConstructor;
import org.eclipse.jgit.api.errors.GitAPIException;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestParam;
import java.io.IOException;
import java.util.List;
@Controller
@RequiredArgsConstructor
public class RepoController
{
private final GitService gitService;
@GetMapping("/repo/{name}")
public String repo(@PathVariable String name, Model model) throws IOException, GitAPIException
{
model.addAttribute("name", name);
model.addAttribute("currentBranch", gitService.getCurrentBranch(name));
model.addAttribute("branches", gitService.listBranches(name));
model.addAttribute("modifiedFiles", gitService.getModifiedFiles(name));
model.addAttribute("stagedFiles", gitService.getStagedFiles(name));
return "repo";
}
@PostMapping("/repo/{name}/checkout")
public String checkout(@PathVariable String name, @RequestParam String branch) throws IOException, GitAPIException
{
gitService.checkoutBranch(name, branch);
return "redirect:/repo/" + name;
}
@PostMapping("/repo/{name}/new-branch")
public String newBranch(@PathVariable String name, @RequestParam String branch) throws IOException, GitAPIException
{
gitService.createAndCheckoutBranch(name, branch);
return "redirect:/repo/" + name;
}
@PostMapping("/repo/{name}/stage")
public String stage(@PathVariable String name, @RequestParam List<String> files) throws IOException, GitAPIException
{
gitService.stageFiles(name, files);
return "redirect:/repo/" + name;
}
@PostMapping("/repo/{name}/commit")
public String commit(@PathVariable String name, @RequestParam String message) throws IOException, GitAPIException
{
gitService.commit(name, message);
return "redirect:/repo/" + name;
}
@PostMapping("/repo/{name}/push")
public String push(@PathVariable String name) throws IOException, GitAPIException
{
gitService.push(name);
return "redirect:/repo/" + name;
}
@PostMapping("/repo/{name}/pull")
public String pull(@PathVariable String name) throws IOException, GitAPIException
{
gitService.pull(name);
return "redirect:/repo/" + name;
}
}

View File

@@ -0,0 +1,43 @@
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<title>WebGit</title>
</head>
<body>
<h1>WebGit</h1>
<h2>Repositories</h2>
<table border="1" cellpadding="4" cellspacing="0">
<tr>
<th>Name</th>
<th>Actions</th>
</tr>
<tr th:each="repo : ${repositories}">
<td th:text="${repo}"></td>
<td><a th:href="@{/repo/{name}(name=${repo})}">Open</a></td>
</tr>
<tr th:if="${#lists.isEmpty(repositories)}">
<td colspan="2"><i>No repositories cloned yet.</i></td>
</tr>
</table>
<h2>Clone a Repository</h2>
<form method="post" action="/clone">
<table border="0" cellpadding="4" cellspacing="0">
<tr>
<td>URL:</td>
<td><input type="text" name="url" size="60"></td>
</tr>
<tr>
<td>Name:</td>
<td><input type="text" name="name" size="30"></td>
</tr>
<tr>
<td></td>
<td><input type="submit" value="Clone"></td>
</tr>
</table>
</form>
</body>
</html>

View File

@@ -0,0 +1,97 @@
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<title th:text="'WebGit - ' + ${name}">WebGit - repo</title>
</head>
<body>
<h1><a href="/">WebGit</a></h1>
<h2 th:text="${name}">repo</h2>
<h3>Branch</h3>
<table border="0" cellpadding="4" cellspacing="0">
<tr>
<td>Current branch:</td>
<td><b th:text="${currentBranch}"></b></td>
</tr>
</table>
<table border="0" cellpadding="4" cellspacing="0">
<tr>
<td>
<form method="post" th:action="@{/repo/{name}/checkout(name=${name})}">
Switch to:
<select name="branch">
<option th:each="b : ${branches}" th:value="${b}" th:text="${b}" th:selected="${b == currentBranch}"></option>
</select>
<input type="submit" value="Checkout">
</form>
</td>
<td>
<form method="post" th:action="@{/repo/{name}/new-branch(name=${name})}">
New branch: <input type="text" name="branch" size="20">
<input type="submit" value="Create">
</form>
</td>
</tr>
</table>
<hr>
<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>File</th>
</tr>
<tr th:each="file : ${modifiedFiles}">
<td><input type="checkbox" name="files" th:value="${file}"></td>
<td th:text="${file}"></td>
</tr>
</table>
<br>
<input type="submit" value="Stage Selected">
</form>
<p th:if="${#lists.isEmpty(modifiedFiles)}"><i>No modified files.</i></p>
<h3>Staged Files</h3>
<table border="1" cellpadding="4" cellspacing="0" th:if="${!#lists.isEmpty(stagedFiles)}">
<tr>
<th>File</th>
</tr>
<tr th:each="file : ${stagedFiles}">
<td th:text="${file}"></td>
</tr>
</table>
<p th:if="${#lists.isEmpty(stagedFiles)}"><i>No staged files.</i></p>
<form method="post" th:action="@{/repo/{name}/commit(name=${name})}" th:if="${!#lists.isEmpty(stagedFiles)}">
<table border="0" cellpadding="4" cellspacing="0">
<tr>
<td>Commit message:</td>
<td><input type="text" name="message" size="60"></td>
<td><input type="submit" value="Commit"></td>
</tr>
</table>
</form>
<hr>
<h3>Remote</h3>
<table border="0" cellpadding="4" cellspacing="0">
<tr>
<td>
<form method="post" th:action="@{/repo/{name}/push(name=${name})}">
<input type="submit" value="Push">
</form>
</td>
<td>
<form method="post" th:action="@{/repo/{name}/pull(name=${name})}">
<input type="submit" value="Pull">
</form>
</td>
</tr>
</table>
</body>
</html>

View File

@@ -0,0 +1,61 @@
package be.seeseepuff.webgit.controller;
import be.seeseepuff.webgit.service.GitService;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.test.web.servlet.MockMvc;
import java.util.List;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
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.*;
@WebMvcTest(HomeController.class)
class HomeControllerTest
{
@Autowired
private MockMvc mockMvc;
@MockitoBean
private GitService gitService;
@Test
void homePageShowsRepositories() throws Exception
{
when(gitService.listRepositories()).thenReturn(List.of("repo1", "repo2"));
mockMvc.perform(get("/"))
.andExpect(status().isOk())
.andExpect(view().name("home"))
.andExpect(model().attribute("repositories", List.of("repo1", "repo2")))
.andExpect(content().string(org.hamcrest.Matchers.containsString("repo1")))
.andExpect(content().string(org.hamcrest.Matchers.containsString("repo2")));
}
@Test
void homePageShowsEmptyList() throws Exception
{
when(gitService.listRepositories()).thenReturn(List.of());
mockMvc.perform(get("/"))
.andExpect(status().isOk())
.andExpect(content().string(org.hamcrest.Matchers.containsString("No repositories cloned yet.")));
}
@Test
void cloneRedirectsToHome() throws Exception
{
mockMvc.perform(post("/clone")
.param("url", "https://example.com/repo.git")
.param("name", "myrepo"))
.andExpect(status().is3xxRedirection())
.andExpect(redirectedUrl("/"));
verify(gitService).cloneRepository("https://example.com/repo.git", "myrepo");
}
}

View File

@@ -0,0 +1,125 @@
package be.seeseepuff.webgit.controller;
import be.seeseepuff.webgit.service.GitService;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.test.web.servlet.MockMvc;
import java.util.List;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
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.*;
@WebMvcTest(RepoController.class)
class RepoControllerTest
{
@Autowired
private MockMvc mockMvc;
@MockitoBean
private GitService gitService;
@Test
void repoPageShowsDetails() throws Exception
{
when(gitService.getCurrentBranch("myrepo")).thenReturn("main");
when(gitService.listBranches("myrepo")).thenReturn(List.of("main", "develop"));
when(gitService.getModifiedFiles("myrepo")).thenReturn(List.of("file1.txt"));
when(gitService.getStagedFiles("myrepo")).thenReturn(List.of("file2.txt"));
mockMvc.perform(get("/repo/myrepo"))
.andExpect(status().isOk())
.andExpect(view().name("repo"))
.andExpect(model().attribute("name", "myrepo"))
.andExpect(model().attribute("currentBranch", "main"))
.andExpect(model().attribute("branches", List.of("main", "develop")))
.andExpect(model().attribute("modifiedFiles", List.of("file1.txt")))
.andExpect(model().attribute("stagedFiles", List.of("file2.txt")))
.andExpect(content().string(org.hamcrest.Matchers.containsString("myrepo")))
.andExpect(content().string(org.hamcrest.Matchers.containsString("main")));
}
@Test
void repoPageShowsNoModifiedFiles() throws Exception
{
when(gitService.getCurrentBranch("myrepo")).thenReturn("main");
when(gitService.listBranches("myrepo")).thenReturn(List.of("main"));
when(gitService.getModifiedFiles("myrepo")).thenReturn(List.of());
when(gitService.getStagedFiles("myrepo")).thenReturn(List.of());
mockMvc.perform(get("/repo/myrepo"))
.andExpect(status().isOk())
.andExpect(content().string(org.hamcrest.Matchers.containsString("No modified files.")))
.andExpect(content().string(org.hamcrest.Matchers.containsString("No staged files.")));
}
@Test
void checkoutRedirectsToRepo() throws Exception
{
mockMvc.perform(post("/repo/myrepo/checkout")
.param("branch", "develop"))
.andExpect(status().is3xxRedirection())
.andExpect(redirectedUrl("/repo/myrepo"));
verify(gitService).checkoutBranch("myrepo", "develop");
}
@Test
void newBranchRedirectsToRepo() throws Exception
{
mockMvc.perform(post("/repo/myrepo/new-branch")
.param("branch", "feature-z"))
.andExpect(status().is3xxRedirection())
.andExpect(redirectedUrl("/repo/myrepo"));
verify(gitService).createAndCheckoutBranch("myrepo", "feature-z");
}
@Test
void stageRedirectsToRepo() throws Exception
{
mockMvc.perform(post("/repo/myrepo/stage")
.param("files", "a.txt")
.param("files", "b.txt"))
.andExpect(status().is3xxRedirection())
.andExpect(redirectedUrl("/repo/myrepo"));
verify(gitService).stageFiles("myrepo", List.of("a.txt", "b.txt"));
}
@Test
void commitRedirectsToRepo() throws Exception
{
mockMvc.perform(post("/repo/myrepo/commit")
.param("message", "test commit"))
.andExpect(status().is3xxRedirection())
.andExpect(redirectedUrl("/repo/myrepo"));
verify(gitService).commit("myrepo", "test commit");
}
@Test
void pushRedirectsToRepo() throws Exception
{
mockMvc.perform(post("/repo/myrepo/push"))
.andExpect(status().is3xxRedirection())
.andExpect(redirectedUrl("/repo/myrepo"));
verify(gitService).push("myrepo");
}
@Test
void pullRedirectsToRepo() throws Exception
{
mockMvc.perform(post("/repo/myrepo/pull"))
.andExpect(status().is3xxRedirection())
.andExpect(redirectedUrl("/repo/myrepo"));
verify(gitService).pull("myrepo");
}
}