diff --git a/src/main/java/be/seeseepuff/webgit/config/WebgitProperties.java b/src/main/java/be/seeseepuff/webgit/config/WebgitProperties.java index 139c6e5..74ee9d1 100644 --- a/src/main/java/be/seeseepuff/webgit/config/WebgitProperties.java +++ b/src/main/java/be/seeseepuff/webgit/config/WebgitProperties.java @@ -15,4 +15,5 @@ public class WebgitProperties { private Path worktreePath; private Path gitDirPath; + private Integer telnetPort; } diff --git a/src/main/java/be/seeseepuff/webgit/telnet/TelnetServer.java b/src/main/java/be/seeseepuff/webgit/telnet/TelnetServer.java new file mode 100644 index 0000000..63b6c9e --- /dev/null +++ b/src/main/java/be/seeseepuff/webgit/telnet/TelnetServer.java @@ -0,0 +1,84 @@ +package be.seeseepuff.webgit.telnet; + +import be.seeseepuff.webgit.config.WebgitProperties; +import be.seeseepuff.webgit.service.GitService; +import jakarta.annotation.PostConstruct; +import jakarta.annotation.PreDestroy; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.stereotype.Component; + +import java.io.*; +import java.net.ServerSocket; +import java.net.Socket; + +@Component +@ConditionalOnProperty(name = "webgit.telnet.enabled", havingValue = "true") +@RequiredArgsConstructor +@Slf4j +public class TelnetServer +{ + private final GitService gitService; + private final WebgitProperties properties; + private ServerSocket serverSocket; + private Thread acceptThread; + + @PostConstruct + public void start() throws IOException + { + int port = properties.getTelnetPort() != null ? properties.getTelnetPort() : 2323; + serverSocket = new ServerSocket(port); + log.info("Telnet server listening on port {}", port); + + acceptThread = new Thread(this::acceptConnections, "telnet-accept"); + acceptThread.setDaemon(true); + acceptThread.start(); + } + + @PreDestroy + public void stop() throws IOException + { + if (serverSocket != null && !serverSocket.isClosed()) + { + serverSocket.close(); + } + } + + private void acceptConnections() + { + while (!serverSocket.isClosed()) + { + try + { + Socket socket = serverSocket.accept(); + log.info("Telnet connection from {}", socket.getRemoteSocketAddress()); + + Thread handler = new Thread(() -> handleConnection(socket), "telnet-client"); + handler.setDaemon(true); + handler.start(); + } + catch (IOException e) + { + if (!serverSocket.isClosed()) + { + log.error("Error accepting telnet connection", e); + } + } + } + } + + private void handleConnection(Socket socket) + { + try (socket; + BufferedReader in = new BufferedReader(new InputStreamReader(socket.getInputStream())); + PrintWriter out = new PrintWriter(socket.getOutputStream(), true)) + { + new TelnetSession(gitService, in, out).run(); + } + catch (IOException e) + { + log.error("Error handling telnet connection", e); + } + } +} diff --git a/src/main/java/be/seeseepuff/webgit/telnet/TelnetSession.java b/src/main/java/be/seeseepuff/webgit/telnet/TelnetSession.java new file mode 100644 index 0000000..c8a533e --- /dev/null +++ b/src/main/java/be/seeseepuff/webgit/telnet/TelnetSession.java @@ -0,0 +1,320 @@ +package be.seeseepuff.webgit.telnet; + +import be.seeseepuff.webgit.service.GitService; +import lombok.RequiredArgsConstructor; +import org.eclipse.jgit.api.errors.GitAPIException; + +import java.io.BufferedReader; +import java.io.IOException; +import java.io.PrintWriter; +import java.util.List; + +@RequiredArgsConstructor +public class TelnetSession implements Runnable +{ + private final GitService gitService; + private final BufferedReader in; + private final PrintWriter out; + + @Override + public void run() + { + try + { + out.println("Welcome to WebGit Telnet Interface"); + out.println("=================================="); + mainMenu(); + } + catch (IOException | GitAPIException e) + { + out.println("Error: " + e.getMessage()); + } + } + + void mainMenu() throws IOException, GitAPIException + { + while (true) + { + out.println(); + out.println("Main Menu:"); + out.println(" 1. List repositories"); + out.println(" 2. Clone a repository"); + out.println(" 3. Open a repository"); + out.println(" q. Quit"); + out.print("> "); + out.flush(); + + String choice = in.readLine(); + if (choice == null || "q".equalsIgnoreCase(choice)) + { + out.println("Goodbye!"); + return; + } + + switch (choice.trim()) + { + case "1" -> listRepositories(); + case "2" -> cloneRepository(); + case "3" -> openRepository(); + default -> out.println("Invalid choice."); + } + } + } + + private void listRepositories() throws IOException + { + List repos = gitService.listRepositories(); + if (repos.isEmpty()) + { + out.println("No repositories cloned yet."); + } + else + { + out.println("Repositories:"); + for (int i = 0; i < repos.size(); i++) + { + out.println(" " + (i + 1) + ". " + repos.get(i)); + } + } + } + + private void cloneRepository() throws IOException, GitAPIException + { + out.print("URL: "); + out.flush(); + String url = in.readLine(); + if (url == null || url.isBlank()) + return; + + out.print("Name: "); + out.flush(); + String name = in.readLine(); + if (name == null || name.isBlank()) + return; + + gitService.cloneRepository(url.trim(), name.trim()); + out.println("Cloned successfully."); + } + + private void openRepository() throws IOException, GitAPIException + { + List repos = gitService.listRepositories(); + if (repos.isEmpty()) + { + out.println("No repositories cloned yet."); + return; + } + + out.println("Repositories:"); + for (int i = 0; i < repos.size(); i++) + { + out.println(" " + (i + 1) + ". " + repos.get(i)); + } + out.print("Enter number: "); + out.flush(); + + String input = in.readLine(); + if (input == null || input.isBlank()) + return; + + int index; + try + { + index = Integer.parseInt(input.trim()) - 1; + } + catch (NumberFormatException e) + { + out.println("Invalid number."); + return; + } + + if (index < 0 || index >= repos.size()) + { + out.println("Invalid selection."); + return; + } + + repoMenu(repos.get(index)); + } + + void repoMenu(String name) throws IOException, GitAPIException + { + while (true) + { + out.println(); + out.println("Repository: " + name); + out.println(" Branch: " + gitService.getCurrentBranch(name)); + out.println(); + out.println(" 1. List branches"); + out.println(" 2. Checkout branch"); + out.println(" 3. Create new branch"); + 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(" b. Back"); + out.print("> "); + out.flush(); + + String choice = in.readLine(); + if (choice == null || "b".equalsIgnoreCase(choice)) + return; + + switch (choice.trim()) + { + case "1" -> listBranches(name); + case "2" -> checkoutBranch(name); + case "3" -> createBranch(name); + case "4" -> showModifiedFiles(name); + case "5" -> showStagedFiles(name); + case "6" -> stageFiles(name); + case "7" -> commitChanges(name); + case "8" -> push(name); + case "9" -> pull(name); + default -> out.println("Invalid choice."); + } + } + } + + private void listBranches(String name) throws IOException, GitAPIException + { + List branches = gitService.listBranches(name); + String current = gitService.getCurrentBranch(name); + out.println("Branches:"); + for (String branch : branches) + { + out.println(branch.equals(current) ? " * " + branch : " " + branch); + } + } + + private void checkoutBranch(String name) throws IOException, GitAPIException + { + out.print("Branch name: "); + out.flush(); + String branch = in.readLine(); + if (branch == null || branch.isBlank()) + return; + gitService.checkoutBranch(name, branch.trim()); + out.println("Switched to " + branch.trim()); + } + + private void createBranch(String name) throws IOException, GitAPIException + { + out.print("New branch name: "); + out.flush(); + String branch = in.readLine(); + if (branch == null || branch.isBlank()) + return; + gitService.createAndCheckoutBranch(name, branch.trim()); + out.println("Created and switched to " + branch.trim()); + } + + private void showModifiedFiles(String name) throws IOException, GitAPIException + { + List files = gitService.getModifiedFiles(name); + if (files.isEmpty()) + { + out.println("No modified files."); + } + else + { + out.println("Modified files:"); + for (int i = 0; i < files.size(); i++) + { + out.println(" " + (i + 1) + ". " + files.get(i)); + } + } + } + + private void showStagedFiles(String name) throws IOException, GitAPIException + { + List files = gitService.getStagedFiles(name); + if (files.isEmpty()) + { + out.println("No staged files."); + } + else + { + out.println("Staged files:"); + for (String file : files) + { + out.println(" " + file); + } + } + } + + private void stageFiles(String name) throws IOException, GitAPIException + { + List files = gitService.getModifiedFiles(name); + if (files.isEmpty()) + { + out.println("No modified files to stage."); + return; + } + + out.println("Modified files:"); + for (int i = 0; i < files.size(); i++) + { + out.println(" " + (i + 1) + ". " + files.get(i)); + } + out.print("Enter numbers to stage (comma-separated, or 'a' for all): "); + out.flush(); + + String input = in.readLine(); + if (input == null || input.isBlank()) + return; + + List toStage; + if ("a".equalsIgnoreCase(input.trim())) + { + toStage = files; + } + else + { + toStage = new java.util.ArrayList<>(); + for (String part : input.split(",")) + { + try + { + int idx = Integer.parseInt(part.trim()) - 1; + if (idx >= 0 && idx < files.size()) + toStage.add(files.get(idx)); + } + catch (NumberFormatException ignored) + { + } + } + } + + if (!toStage.isEmpty()) + { + gitService.stageFiles(name, toStage); + out.println("Staged " + toStage.size() + " file(s)."); + } + } + + private void commitChanges(String name) throws IOException, GitAPIException + { + out.print("Commit message: "); + out.flush(); + String message = in.readLine(); + if (message == null || message.isBlank()) + return; + gitService.commit(name, message.trim()); + out.println("Committed."); + } + + private void push(String name) throws IOException, GitAPIException + { + gitService.push(name); + out.println("Pushed."); + } + + private void pull(String name) throws IOException, GitAPIException + { + gitService.pull(name); + out.println("Pulled."); + } +} diff --git a/src/test/java/be/seeseepuff/webgit/config/WebgitPropertiesTest.java b/src/test/java/be/seeseepuff/webgit/config/WebgitPropertiesTest.java index 551618c..cb4bcbc 100644 --- a/src/test/java/be/seeseepuff/webgit/config/WebgitPropertiesTest.java +++ b/src/test/java/be/seeseepuff/webgit/config/WebgitPropertiesTest.java @@ -12,7 +12,8 @@ import static org.junit.jupiter.api.Assertions.*; @SpringBootTest @TestPropertySource(properties = { "webgit.worktree-path=/mnt/shared/repos", - "webgit.git-dir-path=/var/lib/webgit/git" + "webgit.git-dir-path=/var/lib/webgit/git", + "webgit.telnet-port=2323" }) class WebgitPropertiesTest { @@ -30,4 +31,10 @@ class WebgitPropertiesTest { assertEquals(Path.of("/var/lib/webgit/git"), properties.getGitDirPath()); } + + @Test + void telnetPortIsBound() + { + assertEquals(2323, properties.getTelnetPort()); + } } diff --git a/src/test/java/be/seeseepuff/webgit/telnet/TelnetServerTest.java b/src/test/java/be/seeseepuff/webgit/telnet/TelnetServerTest.java new file mode 100644 index 0000000..747211b --- /dev/null +++ b/src/test/java/be/seeseepuff/webgit/telnet/TelnetServerTest.java @@ -0,0 +1,64 @@ +package be.seeseepuff.webgit.telnet; + +import be.seeseepuff.webgit.config.WebgitProperties; +import be.seeseepuff.webgit.service.GitService; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.io.*; +import java.net.Socket; +import java.nio.file.Path; + +import static org.junit.jupiter.api.Assertions.*; + +class TelnetServerTest +{ + @TempDir + Path tempDir; + + @Test + void serverAcceptsConnectionAndResponds() throws Exception + { + WebgitProperties props = new WebgitProperties(); + props.setWorktreePath(tempDir.resolve("worktrees")); + props.setGitDirPath(tempDir.resolve("gitdirs")); + props.setTelnetPort(0); // use any free port + + GitService gitService = new GitService(props); + + // We'll test with a real server socket but use port 0 for a random free port + // Since the TelnetServer uses PostConstruct, we'll test the component manually + var serverSocket = new java.net.ServerSocket(0); + int port = serverSocket.getLocalPort(); + serverSocket.close(); + + props.setTelnetPort(port); + TelnetServer server = new TelnetServer(gitService, props); + server.start(); + + try (Socket socket = new Socket("localhost", port); + BufferedReader in = new BufferedReader(new InputStreamReader(socket.getInputStream())); + PrintWriter out = new PrintWriter(socket.getOutputStream(), true)) + { + // Read the welcome message + String line = in.readLine(); + assertTrue(line.contains("Welcome to WebGit")); + + // Send quit + out.println("q"); + + // Read until we see Goodbye + String response = ""; + String l; + while ((l = in.readLine()) != null) + { + response += l + "\n"; + } + assertTrue(response.contains("Goodbye!")); + } + finally + { + server.stop(); + } + } +} diff --git a/src/test/java/be/seeseepuff/webgit/telnet/TelnetSessionTest.java b/src/test/java/be/seeseepuff/webgit/telnet/TelnetSessionTest.java new file mode 100644 index 0000000..058d52b --- /dev/null +++ b/src/test/java/be/seeseepuff/webgit/telnet/TelnetSessionTest.java @@ -0,0 +1,352 @@ +package be.seeseepuff.webgit.telnet; + +import be.seeseepuff.webgit.service.GitService; +import org.eclipse.jgit.api.errors.GitAPIException; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.io.*; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class TelnetSessionTest +{ + @Mock + private GitService gitService; + + private TelnetSession createSession(String input) + { + BufferedReader in = new BufferedReader(new StringReader(input)); + PrintWriter out = new PrintWriter(new StringWriter()); + return new TelnetSession(gitService, in, out); + } + + private String runSession(String input) + { + BufferedReader in = new BufferedReader(new StringReader(input)); + StringWriter sw = new StringWriter(); + PrintWriter out = new PrintWriter(sw, true); + new TelnetSession(gitService, in, out).run(); + return sw.toString(); + } + + @Test + void quitExitsImmediately() + { + String output = runSession("q\n"); + assertTrue(output.contains("Welcome to WebGit")); + assertTrue(output.contains("Goodbye!")); + } + + @Test + void nullInputExits() + { + String output = runSession(""); + assertTrue(output.contains("Welcome to WebGit")); + assertTrue(output.contains("Goodbye!")); + } + + @Test + void listRepositoriesShowsEmpty() throws IOException + { + when(gitService.listRepositories()).thenReturn(List.of()); + String output = runSession("1\nq\n"); + assertTrue(output.contains("No repositories cloned yet.")); + } + + @Test + void listRepositoriesShowsRepos() throws IOException + { + when(gitService.listRepositories()).thenReturn(List.of("alpha", "beta")); + String output = runSession("1\nq\n"); + assertTrue(output.contains("alpha")); + assertTrue(output.contains("beta")); + } + + @Test + void cloneRepository() throws IOException, GitAPIException + { + String output = runSession("2\nhttps://example.com/repo.git\nmyrepo\nq\n"); + assertTrue(output.contains("Cloned successfully.")); + verify(gitService).cloneRepository("https://example.com/repo.git", "myrepo"); + } + + @Test + void cloneRepositoryEmptyUrl() + { + String output = runSession("2\n\nq\n"); + assertFalse(output.contains("Cloned successfully.")); + } + + @Test + void cloneRepositoryEmptyName() + { + String output = runSession("2\nhttps://example.com/repo.git\n\nq\n"); + assertFalse(output.contains("Cloned successfully.")); + } + + @Test + void openRepositoryEmpty() throws IOException + { + when(gitService.listRepositories()).thenReturn(List.of()); + String output = runSession("3\nq\n"); + assertTrue(output.contains("No repositories cloned yet.")); + } + + @Test + void openRepositoryInvalidNumber() throws IOException + { + when(gitService.listRepositories()).thenReturn(List.of("myrepo")); + String output = runSession("3\nabc\nq\n"); + assertTrue(output.contains("Invalid number.")); + } + + @Test + void openRepositoryOutOfRange() throws IOException + { + when(gitService.listRepositories()).thenReturn(List.of("myrepo")); + String output = runSession("3\n5\nq\n"); + assertTrue(output.contains("Invalid selection.")); + } + + @Test + void openRepositoryAndGoBack() throws IOException, GitAPIException + { + when(gitService.listRepositories()).thenReturn(List.of("myrepo")); + when(gitService.getCurrentBranch("myrepo")).thenReturn("main"); + String output = runSession("3\n1\nb\nq\n"); + assertTrue(output.contains("Repository: myrepo")); + assertTrue(output.contains("Branch: main")); + } + + @Test + void repoMenuListBranches() throws IOException, GitAPIException + { + when(gitService.listRepositories()).thenReturn(List.of("myrepo")); + when(gitService.getCurrentBranch("myrepo")).thenReturn("main"); + when(gitService.listBranches("myrepo")).thenReturn(List.of("main", "develop")); + String output = runSession("3\n1\n1\nb\nq\n"); + assertTrue(output.contains("* main")); + assertTrue(output.contains("develop")); + } + + @Test + void repoMenuCheckoutBranch() throws IOException, GitAPIException + { + when(gitService.listRepositories()).thenReturn(List.of("myrepo")); + when(gitService.getCurrentBranch("myrepo")).thenReturn("main"); + String output = runSession("3\n1\n2\ndevelop\nb\nq\n"); + verify(gitService).checkoutBranch("myrepo", "develop"); + assertTrue(output.contains("Switched to develop")); + } + + @Test + void repoMenuCheckoutBranchEmpty() throws IOException, GitAPIException + { + when(gitService.listRepositories()).thenReturn(List.of("myrepo")); + when(gitService.getCurrentBranch("myrepo")).thenReturn("main"); + String output = runSession("3\n1\n2\n\nb\nq\n"); + verify(gitService, never()).checkoutBranch(anyString(), anyString()); + } + + @Test + void repoMenuCreateBranch() throws IOException, GitAPIException + { + when(gitService.listRepositories()).thenReturn(List.of("myrepo")); + when(gitService.getCurrentBranch("myrepo")).thenReturn("main"); + String output = runSession("3\n1\n3\nfeature-x\nb\nq\n"); + verify(gitService).createAndCheckoutBranch("myrepo", "feature-x"); + assertTrue(output.contains("Created and switched to feature-x")); + } + + @Test + void repoMenuCreateBranchEmpty() throws IOException, GitAPIException + { + when(gitService.listRepositories()).thenReturn(List.of("myrepo")); + when(gitService.getCurrentBranch("myrepo")).thenReturn("main"); + String output = runSession("3\n1\n3\n\nb\nq\n"); + verify(gitService, never()).createAndCheckoutBranch(anyString(), anyString()); + } + + @Test + void repoMenuShowModifiedFilesEmpty() throws IOException, GitAPIException + { + when(gitService.listRepositories()).thenReturn(List.of("myrepo")); + when(gitService.getCurrentBranch("myrepo")).thenReturn("main"); + when(gitService.getModifiedFiles("myrepo")).thenReturn(List.of()); + String output = runSession("3\n1\n4\nb\nq\n"); + assertTrue(output.contains("No modified files.")); + } + + @Test + void repoMenuShowModifiedFiles() throws IOException, GitAPIException + { + when(gitService.listRepositories()).thenReturn(List.of("myrepo")); + when(gitService.getCurrentBranch("myrepo")).thenReturn("main"); + when(gitService.getModifiedFiles("myrepo")).thenReturn(List.of("a.txt", "b.txt")); + String output = runSession("3\n1\n4\nb\nq\n"); + assertTrue(output.contains("a.txt")); + assertTrue(output.contains("b.txt")); + } + + @Test + void repoMenuShowStagedFilesEmpty() 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\n5\nb\nq\n"); + assertTrue(output.contains("No staged files.")); + } + + @Test + void repoMenuShowStagedFiles() throws IOException, GitAPIException + { + when(gitService.listRepositories()).thenReturn(List.of("myrepo")); + when(gitService.getCurrentBranch("myrepo")).thenReturn("main"); + when(gitService.getStagedFiles("myrepo")).thenReturn(List.of("staged.txt")); + String output = runSession("3\n1\n5\nb\nq\n"); + assertTrue(output.contains("staged.txt")); + } + + @Test + void repoMenuStageAllFiles() throws IOException, GitAPIException + { + when(gitService.listRepositories()).thenReturn(List.of("myrepo")); + when(gitService.getCurrentBranch("myrepo")).thenReturn("main"); + when(gitService.getModifiedFiles("myrepo")).thenReturn(List.of("a.txt", "b.txt")); + String output = runSession("3\n1\n6\na\nb\nq\n"); + verify(gitService).stageFiles("myrepo", List.of("a.txt", "b.txt")); + assertTrue(output.contains("Staged 2 file(s).")); + } + + @Test + void repoMenuStageSelectedFiles() throws IOException, GitAPIException + { + when(gitService.listRepositories()).thenReturn(List.of("myrepo")); + when(gitService.getCurrentBranch("myrepo")).thenReturn("main"); + when(gitService.getModifiedFiles("myrepo")).thenReturn(List.of("a.txt", "b.txt", "c.txt")); + String output = runSession("3\n1\n6\n1,3\nb\nq\n"); + verify(gitService).stageFiles("myrepo", List.of("a.txt", "c.txt")); + assertTrue(output.contains("Staged 2 file(s).")); + } + + @Test + void repoMenuStageNoModifiedFiles() throws IOException, GitAPIException + { + when(gitService.listRepositories()).thenReturn(List.of("myrepo")); + when(gitService.getCurrentBranch("myrepo")).thenReturn("main"); + when(gitService.getModifiedFiles("myrepo")).thenReturn(List.of()); + String output = runSession("3\n1\n6\nb\nq\n"); + assertTrue(output.contains("No modified files to stage.")); + } + + @Test + void repoMenuStageEmptyInput() throws IOException, GitAPIException + { + when(gitService.listRepositories()).thenReturn(List.of("myrepo")); + when(gitService.getCurrentBranch("myrepo")).thenReturn("main"); + when(gitService.getModifiedFiles("myrepo")).thenReturn(List.of("a.txt")); + String output = runSession("3\n1\n6\n\nb\nq\n"); + verify(gitService, never()).stageFiles(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"); + verify(gitService).commit("myrepo", "my commit msg"); + assertTrue(output.contains("Committed.")); + } + + @Test + void repoMenuCommitEmpty() throws IOException, GitAPIException + { + when(gitService.listRepositories()).thenReturn(List.of("myrepo")); + when(gitService.getCurrentBranch("myrepo")).thenReturn("main"); + String output = runSession("3\n1\n7\n\nb\nq\n"); + verify(gitService, never()).commit(anyString(), anyString()); + } + + @Test + void repoMenuPush() throws IOException, GitAPIException + { + when(gitService.listRepositories()).thenReturn(List.of("myrepo")); + when(gitService.getCurrentBranch("myrepo")).thenReturn("main"); + String output = runSession("3\n1\n8\nb\nq\n"); + verify(gitService).push("myrepo"); + assertTrue(output.contains("Pushed.")); + } + + @Test + void repoMenuPull() throws IOException, GitAPIException + { + when(gitService.listRepositories()).thenReturn(List.of("myrepo")); + when(gitService.getCurrentBranch("myrepo")).thenReturn("main"); + String output = runSession("3\n1\n9\nb\nq\n"); + verify(gitService).pull("myrepo"); + assertTrue(output.contains("Pulled.")); + } + + @Test + void invalidMainMenuChoice() + { + String output = runSession("x\nq\n"); + assertTrue(output.contains("Invalid choice.")); + } + + @Test + void invalidRepoMenuChoice() throws IOException, GitAPIException + { + when(gitService.listRepositories()).thenReturn(List.of("myrepo")); + when(gitService.getCurrentBranch("myrepo")).thenReturn("main"); + String output = runSession("3\n1\nx\nb\nq\n"); + assertTrue(output.contains("Invalid choice.")); + } + + @Test + void repoMenuNullInputExits() throws IOException, GitAPIException + { + when(gitService.listRepositories()).thenReturn(List.of("myrepo")); + when(gitService.getCurrentBranch("myrepo")).thenReturn("main"); + // After entering repo menu, the input stream ends (null) + String output = runSession("3\n1\n"); + assertTrue(output.contains("Repository: myrepo")); + } + + @Test + void openRepositoryEmptyInput() throws IOException + { + when(gitService.listRepositories()).thenReturn(List.of("myrepo")); + String output = runSession("3\n\nq\n"); + // Empty input returns to main menu + assertFalse(output.contains("Repository: myrepo")); + } + + @Test + void openRepositoryZeroIndex() throws IOException + { + when(gitService.listRepositories()).thenReturn(List.of("myrepo")); + String output = runSession("3\n0\nq\n"); + assertTrue(output.contains("Invalid selection.")); + } + + @Test + void stageInvalidNumbers() throws IOException, GitAPIException + { + when(gitService.listRepositories()).thenReturn(List.of("myrepo")); + when(gitService.getCurrentBranch("myrepo")).thenReturn("main"); + when(gitService.getModifiedFiles("myrepo")).thenReturn(List.of("a.txt")); + // "abc" is not a valid number - no files staged + String output = runSession("3\n1\n6\nabc\nb\nq\n"); + verify(gitService, never()).stageFiles(anyString(), anyList()); + } +}