Add telnet interface for FreeDOS users

Telnet server with text-based menu for all Git operations.
Enabled via webgit.telnet.enabled=true property, defaults to
port 2323. Includes unit tests for session and server.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
2026-02-26 08:45:25 +01:00
parent 88385d39a1
commit 6218fe345a
6 changed files with 829 additions and 1 deletions

View File

@@ -15,4 +15,5 @@ public class WebgitProperties
{ {
private Path worktreePath; private Path worktreePath;
private Path gitDirPath; private Path gitDirPath;
private Integer telnetPort;
} }

View File

@@ -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);
}
}
}

View File

@@ -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<String> 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<String> 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<String> 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<String> 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<String> 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<String> 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<String> 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.");
}
}

View File

@@ -12,7 +12,8 @@ import static org.junit.jupiter.api.Assertions.*;
@SpringBootTest @SpringBootTest
@TestPropertySource(properties = { @TestPropertySource(properties = {
"webgit.worktree-path=/mnt/shared/repos", "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 class WebgitPropertiesTest
{ {
@@ -30,4 +31,10 @@ class WebgitPropertiesTest
{ {
assertEquals(Path.of("/var/lib/webgit/git"), properties.getGitDirPath()); assertEquals(Path.of("/var/lib/webgit/git"), properties.getGitDirPath());
} }
@Test
void telnetPortIsBound()
{
assertEquals(2323, properties.getTelnetPort());
}
} }

View File

@@ -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();
}
}
}

View File

@@ -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());
}
}