Add unstage files feature

Add GitService.unstageFiles() using JGit reset to move files from
staged back to modified. Exposed via POST /repo/{name}/unstage
endpoint, repo page with checkboxes, and telnet menu option 7.
Includes unit tests for all layers.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
2026-02-26 08:56:20 +01:00
parent 39f09f03ff
commit 159332ff43
10 changed files with 164 additions and 17 deletions

View File

@@ -51,6 +51,13 @@ public class RepoController
return "redirect:/repo/" + name;
}
@PostMapping("/repo/{name}/unstage")
public String unstage(@PathVariable String name, @RequestParam List<String> files) throws IOException, GitAPIException
{
gitService.unstageFiles(name, files);
return "redirect:/repo/" + name;
}
@PostMapping("/repo/{name}/commit")
public String commit(@PathVariable String name, @RequestParam String message) throws IOException, GitAPIException
{

View File

@@ -158,6 +158,19 @@ public class GitService
}
}
public void unstageFiles(String name, List<String> files) throws IOException, GitAPIException
{
try (Git git = openRepository(name))
{
var reset = git.reset();
for (String file : files)
{
reset.addPath(file);
}
reset.call();
}
}
public void push(String name) throws IOException, GitAPIException
{
try (Git git = openRepository(name))

View File

@@ -151,9 +151,10 @@ public class TelnetSession implements Runnable
out.println(" 4. Show modified files");
out.println(" 5. Show staged files");
out.println(" 6. Stage files");
out.println(" 7. Commit");
out.println(" 8. Push");
out.println(" 9. Pull");
out.println(" 7. Unstage files");
out.println(" 8. Commit");
out.println(" 9. Push");
out.println(" 10. Pull");
out.println(" b. Back");
out.print("> ");
out.flush();
@@ -170,9 +171,10 @@ public class TelnetSession implements Runnable
case "4" -> showModifiedFiles(name);
case "5" -> showStagedFiles(name);
case "6" -> stageFiles(name);
case "7" -> commitChanges(name);
case "8" -> push(name);
case "9" -> pull(name);
case "7" -> unstageFiles(name);
case "8" -> commitChanges(name);
case "9" -> push(name);
case "10" -> pull(name);
default -> out.println("Invalid choice.");
}
}
@@ -295,6 +297,56 @@ public class TelnetSession implements Runnable
}
}
private void unstageFiles(String name) throws IOException, GitAPIException
{
List<String> files = gitService.getStagedFiles(name);
if (files.isEmpty())
{
out.println("No staged files to unstage.");
return;
}
out.println("Staged files:");
for (int i = 0; i < files.size(); i++)
{
out.println(" " + (i + 1) + ". " + files.get(i));
}
out.print("Enter numbers to unstage (comma-separated, or 'a' for all): ");
out.flush();
String input = in.readLine();
if (input == null || input.isBlank())
return;
List<String> toUnstage;
if ("a".equalsIgnoreCase(input.trim()))
{
toUnstage = files;
}
else
{
toUnstage = new java.util.ArrayList<>();
for (String part : input.split(","))
{
try
{
int idx = Integer.parseInt(part.trim()) - 1;
if (idx >= 0 && idx < files.size())
toUnstage.add(files.get(idx));
}
catch (NumberFormatException ignored)
{
}
}
}
if (!toUnstage.isEmpty())
{
gitService.unstageFiles(name, toUnstage);
out.println("Unstaged " + toUnstage.size() + " file(s).");
}
}
private void commitChanges(String name) throws IOException, GitAPIException
{
out.print("Commit message: ");

View File

@@ -1 +1,4 @@
spring.application.name=webgit
webgit.git-dir-path=./webgit/gitdir
webgit.worktree-path=./webgit/worktree
webgit.telnet-port=2323

View File

@@ -55,14 +55,20 @@ New branch: <input type="text" name="branch" size="20">
<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)}">
<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>File</th>
</tr>
<tr th:each="file : ${stagedFiles}">
<td><input type="checkbox" name="files" th:value="${file}"></td>
<td th:text="${file}"></td>
</tr>
</table>
<br>
<input type="submit" value="Unstage Selected">
</form>
<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)}">

View File

@@ -10,9 +10,4 @@ class WebgitApplicationTests {
void contextLoads() {
}
@Test
void mainMethodRuns() {
WebgitApplication.main(new String[]{});
}
}

View File

@@ -92,6 +92,18 @@ class RepoControllerTest
verify(gitService).stageFiles("myrepo", List.of("a.txt", "b.txt"));
}
@Test
void unstageRedirectsToRepo() throws Exception
{
mockMvc.perform(post("/repo/myrepo/unstage")
.param("files", "a.txt")
.param("files", "b.txt"))
.andExpect(status().is3xxRedirection())
.andExpect(redirectedUrl("/repo/myrepo"));
verify(gitService).unstageFiles("myrepo", List.of("a.txt", "b.txt"));
}
@Test
void commitRedirectsToRepo() throws Exception
{

View File

@@ -209,6 +209,20 @@ class GitServiceTest
assertDoesNotThrow(() -> gitService.push("myrepo"));
}
@Test
void unstageFilesMovesBackToModified() throws GitAPIException, IOException
{
gitService.cloneRepository(bareRemote.toUri().toString(), "myrepo");
Files.writeString(worktreePath.resolve("myrepo/newfile.txt"), "hello");
gitService.stageFiles("myrepo", List.of("newfile.txt"));
assertTrue(gitService.getStagedFiles("myrepo").contains("newfile.txt"));
gitService.unstageFiles("myrepo", List.of("newfile.txt"));
assertFalse(gitService.getStagedFiles("myrepo").contains("newfile.txt"));
assertTrue(gitService.getModifiedFiles("myrepo").contains("newfile.txt"));
}
@Test
void pullFetchesFromRemote() throws GitAPIException, IOException
{

View File

@@ -257,12 +257,54 @@ class TelnetSessionTest
verify(gitService, never()).stageFiles(anyString(), anyList());
}
@Test
void repoMenuUnstageAllFiles() throws IOException, GitAPIException
{
when(gitService.listRepositories()).thenReturn(List.of("myrepo"));
when(gitService.getCurrentBranch("myrepo")).thenReturn("main");
when(gitService.getStagedFiles("myrepo")).thenReturn(List.of("a.txt", "b.txt"));
String output = runSession("3\n1\n7\na\nb\nq\n");
verify(gitService).unstageFiles("myrepo", List.of("a.txt", "b.txt"));
assertTrue(output.contains("Unstaged 2 file(s)."));
}
@Test
void repoMenuUnstageSelectedFiles() throws IOException, GitAPIException
{
when(gitService.listRepositories()).thenReturn(List.of("myrepo"));
when(gitService.getCurrentBranch("myrepo")).thenReturn("main");
when(gitService.getStagedFiles("myrepo")).thenReturn(List.of("a.txt", "b.txt", "c.txt"));
String output = runSession("3\n1\n7\n1,3\nb\nq\n");
verify(gitService).unstageFiles("myrepo", List.of("a.txt", "c.txt"));
assertTrue(output.contains("Unstaged 2 file(s)."));
}
@Test
void repoMenuUnstageNoStagedFiles() throws IOException, GitAPIException
{
when(gitService.listRepositories()).thenReturn(List.of("myrepo"));
when(gitService.getCurrentBranch("myrepo")).thenReturn("main");
when(gitService.getStagedFiles("myrepo")).thenReturn(List.of());
String output = runSession("3\n1\n7\nb\nq\n");
assertTrue(output.contains("No staged files to unstage."));
}
@Test
void repoMenuUnstageEmptyInput() throws IOException, GitAPIException
{
when(gitService.listRepositories()).thenReturn(List.of("myrepo"));
when(gitService.getCurrentBranch("myrepo")).thenReturn("main");
when(gitService.getStagedFiles("myrepo")).thenReturn(List.of("a.txt"));
String output = runSession("3\n1\n7\n\nb\nq\n");
verify(gitService, never()).unstageFiles(anyString(), anyList());
}
@Test
void repoMenuCommit() throws IOException, GitAPIException
{
when(gitService.listRepositories()).thenReturn(List.of("myrepo"));
when(gitService.getCurrentBranch("myrepo")).thenReturn("main");
String output = runSession("3\n1\n7\nmy commit msg\nb\nq\n");
String output = runSession("3\n1\n8\nmy commit msg\nb\nq\n");
verify(gitService).commit("myrepo", "my commit msg");
assertTrue(output.contains("Committed."));
}
@@ -272,7 +314,7 @@ class TelnetSessionTest
{
when(gitService.listRepositories()).thenReturn(List.of("myrepo"));
when(gitService.getCurrentBranch("myrepo")).thenReturn("main");
String output = runSession("3\n1\n7\n\nb\nq\n");
String output = runSession("3\n1\n8\n\nb\nq\n");
verify(gitService, never()).commit(anyString(), anyString());
}
@@ -281,7 +323,7 @@ class TelnetSessionTest
{
when(gitService.listRepositories()).thenReturn(List.of("myrepo"));
when(gitService.getCurrentBranch("myrepo")).thenReturn("main");
String output = runSession("3\n1\n8\nb\nq\n");
String output = runSession("3\n1\n9\nb\nq\n");
verify(gitService).push("myrepo");
assertTrue(output.contains("Pushed."));
}
@@ -291,7 +333,7 @@ class TelnetSessionTest
{
when(gitService.listRepositories()).thenReturn(List.of("myrepo"));
when(gitService.getCurrentBranch("myrepo")).thenReturn("main");
String output = runSession("3\n1\n9\nb\nq\n");
String output = runSession("3\n1\n10\nb\nq\n");
verify(gitService).pull("myrepo");
assertTrue(output.contains("Pulled."));
}
@@ -404,7 +446,7 @@ class TelnetSessionTest
{
when(gitService.listRepositories()).thenReturn(List.of("myrepo"));
when(gitService.getCurrentBranch("myrepo")).thenReturn("main");
String output = runSession("3\n1\n7\n");
String output = runSession("3\n1\n8\n");
verify(gitService, never()).commit(anyString(), anyString());
}