From 159332ff432c84a83f12f1260e05391064155bb0 Mon Sep 17 00:00:00 2001 From: Sebastiaan de Schaetzen Date: Thu, 26 Feb 2026 08:56:20 +0100 Subject: [PATCH] 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> --- .gitignore | 3 + .../webgit/controller/RepoController.java | 7 ++ .../seeseepuff/webgit/service/GitService.java | 13 ++++ .../webgit/telnet/TelnetSession.java | 64 +++++++++++++++++-- src/main/resources/application.properties | 3 + src/main/resources/templates/repo.html | 8 ++- .../webgit/WebgitApplicationTests.java | 5 -- .../webgit/controller/RepoControllerTest.java | 12 ++++ .../webgit/service/GitServiceTest.java | 14 ++++ .../webgit/telnet/TelnetSessionTest.java | 52 +++++++++++++-- 10 files changed, 164 insertions(+), 17 deletions(-) diff --git a/.gitignore b/.gitignore index c2065bc..1a300bd 100644 --- a/.gitignore +++ b/.gitignore @@ -35,3 +35,6 @@ out/ ### VS Code ### .vscode/ + +# WebGit +/webgit/ diff --git a/src/main/java/be/seeseepuff/webgit/controller/RepoController.java b/src/main/java/be/seeseepuff/webgit/controller/RepoController.java index 8213c75..9a9363b 100644 --- a/src/main/java/be/seeseepuff/webgit/controller/RepoController.java +++ b/src/main/java/be/seeseepuff/webgit/controller/RepoController.java @@ -51,6 +51,13 @@ public class RepoController return "redirect:/repo/" + name; } + @PostMapping("/repo/{name}/unstage") + public String unstage(@PathVariable String name, @RequestParam List 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 { diff --git a/src/main/java/be/seeseepuff/webgit/service/GitService.java b/src/main/java/be/seeseepuff/webgit/service/GitService.java index fef468e..3926f32 100644 --- a/src/main/java/be/seeseepuff/webgit/service/GitService.java +++ b/src/main/java/be/seeseepuff/webgit/service/GitService.java @@ -158,6 +158,19 @@ public class GitService } } + public void unstageFiles(String name, List 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)) diff --git a/src/main/java/be/seeseepuff/webgit/telnet/TelnetSession.java b/src/main/java/be/seeseepuff/webgit/telnet/TelnetSession.java index c8a533e..325b1df 100644 --- a/src/main/java/be/seeseepuff/webgit/telnet/TelnetSession.java +++ b/src/main/java/be/seeseepuff/webgit/telnet/TelnetSession.java @@ -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 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 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: "); diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 299aec1..2a425ce 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -1 +1,4 @@ spring.application.name=webgit +webgit.git-dir-path=./webgit/gitdir +webgit.worktree-path=./webgit/worktree +webgit.telnet-port=2323 diff --git a/src/main/resources/templates/repo.html b/src/main/resources/templates/repo.html index f3df61e..e7e5b97 100644 --- a/src/main/resources/templates/repo.html +++ b/src/main/resources/templates/repo.html @@ -55,14 +55,20 @@ New branch:

No modified files.

Staged Files

- + +
+ +
Unstage File
+
+ +

No staged files.

diff --git a/src/test/java/be/seeseepuff/webgit/WebgitApplicationTests.java b/src/test/java/be/seeseepuff/webgit/WebgitApplicationTests.java index ed8e998..2c38a47 100644 --- a/src/test/java/be/seeseepuff/webgit/WebgitApplicationTests.java +++ b/src/test/java/be/seeseepuff/webgit/WebgitApplicationTests.java @@ -10,9 +10,4 @@ class WebgitApplicationTests { void contextLoads() { } - @Test - void mainMethodRuns() { - WebgitApplication.main(new String[]{}); - } - } diff --git a/src/test/java/be/seeseepuff/webgit/controller/RepoControllerTest.java b/src/test/java/be/seeseepuff/webgit/controller/RepoControllerTest.java index f1e60b6..3ceb6e3 100644 --- a/src/test/java/be/seeseepuff/webgit/controller/RepoControllerTest.java +++ b/src/test/java/be/seeseepuff/webgit/controller/RepoControllerTest.java @@ -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 { diff --git a/src/test/java/be/seeseepuff/webgit/service/GitServiceTest.java b/src/test/java/be/seeseepuff/webgit/service/GitServiceTest.java index c5c0846..a3d70c1 100644 --- a/src/test/java/be/seeseepuff/webgit/service/GitServiceTest.java +++ b/src/test/java/be/seeseepuff/webgit/service/GitServiceTest.java @@ -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 { diff --git a/src/test/java/be/seeseepuff/webgit/telnet/TelnetSessionTest.java b/src/test/java/be/seeseepuff/webgit/telnet/TelnetSessionTest.java index 3482ef2..c02deab 100644 --- a/src/test/java/be/seeseepuff/webgit/telnet/TelnetSessionTest.java +++ b/src/test/java/be/seeseepuff/webgit/telnet/TelnetSessionTest.java @@ -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()); }