package solutions.tretter.githugandroid object GitSandboxEngine { data class ShellToken( val value: String, val quoted: Boolean = false, ) fun commandReferenceLines(): List = listOf( "Available sandbox commands:", " git ", " ls|dir", " touch ", " help", " pwd ", " cat ", " touch ", " mkdir|md ", " cd ", " cd..", " rm|del ", " echo ", ) fun execute(repo: RepoState, command: String): Pair> { val shellParts = tokenizeShellCommand(command) val parts = shellParts.map { it.value } if (parts.isEmpty()) return repo to emptyList() repo.interactiveAddSession?.let { return handleInteractiveAddInput(repo, command) } return when { parts[0] == "help" || parts[0] == "?" -> repo to commandReferenceLines() parts[0] == "touch" && parts.size >= 2 -> { val name = parts[1] if (repo.files.any { it.name == name && !it.deleted }) repo to listOf("$name already exists") else repo.copy(files = repo.files + GitFile(name = name)) to emptyList() } (parts[0] == "mkdir" || parts[0] == "md") && parts.size >= 2 -> repo to emptyList() (parts[0] == "rm" || parts[0] == "del") && parts.size >= 2 -> { val target = parts[1] repo.copy(files = repo.files.mapNotNull { file -> when { file.name != target -> file file.tracked -> file.copy(deleted = true, staged = false) else -> null } }) to emptyList() } parts[0] == "echo" -> writeEcho(repo, shellParts) parts[0] == "ls" || parts[0] == "dir" -> repo to ( if (repo.initialized) listOf(".git") else emptyList() ) + repo.files.filterNot { it.deleted }.map { it.name } parts[0] == "cd.." -> repo.copy(currentDir = parentDirectory(repo.currentDir)) to emptyList() parts[0] != "git" -> repo to listOf("Command not supported in sandbox. Try a git command or 'touch'.") parts.size >= 2 && parts[1] == "init" -> repo.copy(initialized = true, branches = mapOf("master" to repo.commits.size)) to listOf("Initialized empty Git repository") !repo.initialized -> repo to listOf("fatal: not a git repository") parts.size >= 2 && parts[1] == "help" -> repo to commandReferenceLines() parts.size >= 2 && parts[1] == "status" -> repo to statusLines(repo) parts.size >= 2 && parts[1] == "stash" -> { val updatedFiles = repo.files.map { file -> if (file.tracked && !file.staged) file.copy(content = "") else file } repo.copy(files = updatedFiles, stashes = repo.stashes + "stash@{${repo.stashes.size}}") to listOf("Saved working directory and index state") } parts.size >= 2 && parts[1] == "fetch" -> { val remote = parts.getOrNull(2)?.takeIf { !it.startsWith("-") } ?: "origin" repo.copy( fetchedBranches = repo.fetchedBranches + listOf("$remote/master", "$remote/new_branch"), fetchHeadCount = 2, maintenanceActions = repo.maintenanceActions + "fetch", ) to emptyList() } parts.size >= 2 && parts[1] == "pull" -> { val remote = parts.getOrNull(2)?.takeIf { !it.startsWith("-") } ?: "origin" val branch = parts.drop(2).lastOrNull()?.takeIf { !it.startsWith("-") && it != remote } ?: repo.headBranch repo.copy( fetchedBranches = repo.fetchedBranches + "$remote/$branch", fetchHeadCount = 1, branches = repo.branches + (repo.headBranch to maxOf(repo.branches[repo.headBranch] ?: 0, 2)), maintenanceActions = repo.maintenanceActions + "pull", ) to emptyList() } parts.size >= 2 && parts[1] == "push" -> pushRefs(repo, parts.drop(2)) parts.size >= 3 && parts[1] == "submodule" && parts[2] == "add" -> { val url = parts.getOrNull(3) val path = parts.getOrNull(4) if (url == null || path == null) { repo to listOf("usage: git submodule add ") } else { repo.copy(submodules = repo.submodules + (path.trimEnd('/') to url)) to emptyList() } } parts.size >= 2 && parts[1] == "repack" -> { repo.copy(maintenanceActions = repo.maintenanceActions + "repack") to emptyList() } parts.size >= 3 && parts[1] == "tag" -> { val tag = parts[2] if (tag in repo.tags) repo to listOf("fatal: tag '$tag' already exists") else repo.copy(tags = repo.tags + tag) to listOf(tag) } parts.size >= 4 && parts[1] == "config" -> { val key = parts[2] val value = parts.drop(3).joinToString(" ") repo.copy(config = repo.config + (key to value)) to emptyList() } parts.size >= 2 && (parts[1] == "stage" || parts[1] == "add") -> { if (parts.drop(2).any { it == "-i" || it == "--interactive" }) { return interactiveAdd(repo, parts.drop(2)) } val target = parts.drop(2).lastOrNull { !it.startsWith("-") } ?: return repo to listOf("usage: git add ") if (target != "." && repo.files.none { it.name == target && !it.deleted }) { repo to listOf("fatal: pathspec '$target' did not match any files") } else { val updated = repo.files.map { if ((target == "." || it.name == target) && !it.deleted) it.copy(staged = true) else it } repo.copy(files = updated) to listOf("staged ${if (target == ".") "all files" else target}") } } parts.size >= 3 && parts[1] == "rm" -> removeGitPath(repo, parts.drop(2)) parts.size >= 4 && parts[1] == "mv" -> moveGitPath(repo, expandPathspecTokens(repo, shellParts.drop(2))) parts.size >= 2 && parts[1] == "commit" -> commit(repo, parts.drop(2)) parts.size >= 2 && parts[1] == "log" -> { repo to if (repo.commits.isEmpty()) listOf("fatal: your current branch '${repo.headBranch}' does not have any commits yet") else repo.commits.reversed().flatMap { listOf("commit ${it.id}", " ${it.message}") } } parts.size >= 3 && parts[1] == "remote" && parts[2] == "add" -> { val name = parts.getOrNull(3) val url = parts.getOrNull(4) if (name == null || url == null) repo to listOf("usage: git remote add ") else repo.copy(remotes = repo.remotes + (name to url)) to emptyList() } parts.size >= 3 && parts[1] == "branch" -> { when (parts[2]) { "-d", "-D", "--delete" -> { val branch = parts.getOrNull(3) if (branch == null) repo to listOf("usage: git branch -d ") else repo.copy(branches = repo.branches - branch) to listOf("Deleted branch $branch") } else -> { val branch = parts[2] val base = parts.getOrNull(3) val baseIndex = if (base == "HEAD~1" || base == "HEAD^") { (repo.branches[repo.headBranch] ?: repo.commits.size) - 1 } else { repo.commits.size }.coerceAtLeast(0) if (repo.branches.containsKey(branch)) repo to listOf("fatal: a branch named '$branch' already exists") else repo.copy(branches = repo.branches + (branch to baseIndex)) to listOf("Created branch $branch") } } } parts.size >= 3 && parts[1] == "checkout" -> { checkout(repo, parts.drop(2)) } parts.size >= 3 && parts[1] == "reset" -> reset(repo, parts.drop(2)) parts.size >= 3 && parts[1] == "merge" -> merge(repo, parts.drop(2)) parts.size >= 2 && parts[1] == "rebase" -> rebase(repo, parts.drop(2)) parts.size >= 2 && parts[1] == "cherry-pick" -> { val files = if (repo.files.none { it.name == "README.md" }) { repo.files + GitFile("README.md", "Proper input instructions\n", tracked = true) } else { repo.files.map { if (it.name == "README.md") it.copy(tracked = true) else it } } repo.copy(files = files, commits = listOf(CommitNode("${repo.commits.size + 1}", "Filled in README.md with proper input")) + repo.commits) to emptyList() } parts.size >= 2 && parts[1] == "revert" -> { repo.copy(commits = repo.commits + CommitNode("${repo.commits.size + 1}", "Revert \"Bad commit\"")) to emptyList() } else -> repo to listOf("Unsupported git command in MVP sandbox: ${parts.drop(1).joinToString(" ")}") } } private fun interactiveAdd(repo: RepoState, arguments: List): Pair> { val targets = arguments.filterNot { it == "-i" || it == "--interactive" || it.startsWith("--") } val target = targets.lastOrNull() val candidates = repo.files.filter { file -> !file.deleted && (target == null || target == "." || file.name == target || file.name.startsWith(target.trimEnd('/') + "/")) } return repo.copy(interactiveAddSession = InteractiveAddSession(target = target)) to interactiveAddConsoleLines(candidates) } private fun interactiveAddConsoleLines(candidates: List): List { return buildList { add(" staged unstaged path") candidates.forEachIndexed { index, file -> val staged = if (file.staged) "unchanged" else "+0/-0" val unstaged = when { file.tracked -> "+1/-0" else -> "+0/-0" } add("${index + 1}: ${staged.padEnd(10)} ${unstaged.padEnd(9)} ${file.name}") } if (candidates.isEmpty()) { add("No changes.") } add("*** Commands ***") add(" 1: status 2: update 3: revert 4: add untracked") add(" 5: patch 6: diff 7: quit 8: help") add("What now>") } } private fun handleInteractiveAddInput(repo: RepoState, input: String): Pair> { val session = repo.interactiveAddSession ?: return repo to emptyList() val answer = input.trim() return if (session.awaitingUpdateSelection) { applyInteractiveAddUpdateSelection(repo, session, answer) } else { when (answer.lowercase()) { "1", "s", "status" -> repo to listOf("What now> $answer") + interactiveAddConsoleLines(interactiveAddCandidates(repo, session.target)) "2", "u", "update" -> repo.copy( interactiveAddSession = session.copy(awaitingUpdateSelection = true), ) to listOf("What now> $answer", "Update>>") "7", "q", "quit" -> repo.copy(interactiveAddSession = null) to listOf("What now> $answer", "Bye.") "8", "h", "help" -> repo to listOf("What now> $answer") + interactiveAddConsoleLines(interactiveAddCandidates(repo, session.target)) else -> repo to listOf("What now> $answer", "Huh ($answer)?") + interactiveAddConsoleLines(interactiveAddCandidates(repo, session.target)) } } } private fun applyInteractiveAddUpdateSelection( repo: RepoState, session: InteractiveAddSession, answer: String, ): Pair> { val candidates = interactiveAddCandidates(repo, session.target) val selectedNames = selectedInteractiveAddNames(candidates, answer) if (selectedNames.isEmpty()) { return repo to listOf("Update>> $answer", "No files selected.", "Update>>") } val updatedFiles = repo.files.map { file -> if (file.name in selectedNames && !file.deleted) file.copy(staged = true) else file } val updatedRepo = repo.copy( files = updatedFiles, interactiveAddSession = session.copy(awaitingUpdateSelection = false), ) val stagedCount = updatedFiles.count { updatedFile -> val before = repo.files.firstOrNull { it.name == updatedFile.name } updatedFile.staged && before?.staged != true } return updatedRepo to listOf( "Update>> $answer", "updated $stagedCount path(s)", ) + interactiveAddConsoleLines(interactiveAddCandidates(updatedRepo, session.target)) } private fun interactiveAddCandidates(repo: RepoState, target: String?): List { return repo.files.filter { file -> !file.deleted && (target == null || target == "." || file.name == target || file.name.startsWith(target.trimEnd('/') + "/")) } } private fun selectedInteractiveAddNames(candidates: List, answer: String): Set { if (answer == "*") return candidates.map { it.name }.toSet() return answer.split(Regex("[,\\s]+")) .mapNotNull { token -> token.toIntOrNull() ?.takeIf { it in 1..candidates.size } ?.let { candidates[it - 1].name } } .toSet() } fun tokenizeCommand(command: String): List { return tokenizeShellCommand(command).map { it.value } } fun tokenizeShellCommand(command: String): List { val result = mutableListOf() val current = StringBuilder() var quoteChar: Char? = null var escaping = false var currentQuoted = false var skipNext = false fun emitCurrent(force: Boolean = false) { if (current.isNotEmpty() || force && currentQuoted) { result += ShellToken(value = current.toString(), quoted = currentQuoted) current.clear() currentQuoted = false } } command.forEachIndexed { index, char -> if (skipNext) { skipNext = false return@forEachIndexed } when { escaping -> { current.append(char) escaping = false } char == '\\' && quoteChar != '\'' -> { escaping = true } quoteChar != null -> { if (char == quoteChar) { quoteChar = null } else { current.append(char) } } char == '"' || char == '\'' -> { quoteChar = char currentQuoted = true } char.isWhitespace() -> { emitCurrent() } char == '>' -> { emitCurrent() if (command.getOrNull(index + 1) == '>') { result += ShellToken(">>") skipNext = true } else if (command.getOrNull(index - 1) != '>') { result += ShellToken(">") } } else -> current.append(char) } } if (escaping) { current.append('\\') } emitCurrent() return result } private fun writeEcho(repo: RepoState, shellParts: List): Pair> { val parts = shellParts.map { it.value } val redirectIndex = parts.indexOfFirst { it == ">" || it == ">>" } if (redirectIndex == -1 || redirectIndex == parts.lastIndex) { return repo to listOf(parts.drop(1).joinToString(" ")) } val append = parts[redirectIndex] == ">>" val content = parts.subList(1, redirectIndex).joinToString(" ") val target = parts[redirectIndex + 1] val updatedFiles = repo.files.toMutableList() val index = updatedFiles.indexOfFirst { it.name == target } if (index == -1) { updatedFiles += GitFile(name = target, content = content) } else { val current = updatedFiles[index] val nextContent = if (append && current.content.isNotEmpty()) "${current.content}\n$content" else content updatedFiles[index] = current.copy(content = nextContent, deleted = false) } return repo.copy(files = updatedFiles) to emptyList() } private fun parentDirectory(currentDir: String): String { if (currentDir == ".") return "." return currentDir.substringBeforeLast('/', missingDelimiterValue = ".").ifBlank { "." } } fun expandPathspecs(repo: RepoState, arguments: List): List { return expandPathspecTokens(repo, arguments.map { ShellToken(it) }) } fun expandPathspecTokens(repo: RepoState, arguments: List): List { return arguments.flatMap { token -> val argument = token.value if (token.quoted || !argument.hasGlob()) { listOf(argument) } else { val regex = argument.globToRegex() repo.files .filterNot { it.deleted } .map { it.name } .filter { regex.matches(it) } .sorted() .ifEmpty { listOf(argument) } } } } private fun pushRefs(repo: RepoState, arguments: List): Pair> { val remote = arguments.firstOrNull { !it.startsWith("-") } ?: "origin" val explicitBranches = arguments .dropWhile { it.startsWith("-") } .drop(1) .filter { !it.startsWith("-") } val pushedBranches = when { arguments.any { it == "--all" } -> repo.branches.keys.map { "$remote/$it" } explicitBranches.isNotEmpty() -> explicitBranches.map { branch -> "$remote/${branch.substringAfterLast(':')}" } else -> listOf("$remote/${repo.headBranch}") } val pushedTags = if (arguments.any { it == "--tags" || it == "--follow-tags" }) { repo.tags.toSet() } else { emptySet() } return repo.copy( pushedBranches = repo.pushedBranches + pushedBranches, pushedTags = repo.pushedTags + pushedTags, ) to emptyList() } private fun removeGitPath(repo: RepoState, arguments: List): Pair> { val cached = "--cached" in arguments val target = arguments.lastOrNull { !it.startsWith("-") } ?: return repo to listOf("usage: git rm [--cached] ") val updated = repo.files.mapNotNull { file -> if (file.name != target) { file } else if (cached) { file.copy(staged = false, tracked = false) } else { null } } return repo.copy(files = updated) to emptyList() } private fun moveGitPath(repo: RepoState, arguments: List): Pair> { val destination = arguments.lastOrNull() ?: return repo to listOf("usage: git mv ") val sources = arguments.dropLast(1) if (sources.isEmpty()) return repo to listOf("usage: git mv ") val destinationIsDirectory = sources.size > 1 || destination.endsWith("/") val updated = repo.files.map { file -> if (file.name in sources) { val target = if (destinationIsDirectory) { destination.trimEnd('/') + "/" + file.name.substringAfterLast('/') } else { destination } file.copy(name = target, staged = true) } else { file } } return repo.copy(files = updated) to emptyList() } private fun checkout(repo: RepoState, arguments: List): Pair> { return when { arguments.firstOrNull() == "-b" -> { val branch = arguments.getOrNull(1) ?: return repo to listOf("usage: git checkout -b ") repo.copy( headBranch = branch, branches = repo.branches + (branch to repo.commits.size), ) to listOf("Switched to a new branch '$branch'") } arguments.firstOrNull() == "-B" -> { val branch = arguments.getOrNull(1) ?: return repo to listOf("usage: git checkout -B ") repo.copy( headBranch = branch, branches = repo.branches + (branch to repo.commits.size), ) to listOf("Switched to branch '$branch'") } "--" in arguments -> { val target = arguments.last() val updated = repo.files.map { file -> when (file.name) { target -> file.copy(content = file.content.substringBefore("\nThese are changes you don't want to keep!")) "file3" -> file else -> file } }.let { files -> if (target == "file3" && files.none { it.name == "file3" }) files + GitFile("file3", tracked = true) else files } repo.copy(files = updated) to emptyList() } arguments.any { it == "file3" } -> { repo.copy(files = repo.files + GitFile("file3", tracked = true)) to emptyList() } else -> { val branch = arguments.first() val normalizedTag = branch.removePrefix("tags/").removePrefix("refs/tags/") when { repo.branches.containsKey(branch) -> repo.copy(headBranch = branch) to listOf("Switched to branch '$branch'") normalizedTag in repo.tags -> repo.copy(headBranch = "tags/$normalizedTag") to listOf("HEAD is now at $normalizedTag") else -> repo to listOf("error: pathspec '$branch' did not match any branch") } } } } private fun reset(repo: RepoState, arguments: List): Pair> { return if ("--soft" in arguments) { repo.copy( commits = repo.commits.dropLast(1), files = repo.files.map { if (it.tracked) it.copy(staged = true) else it }, ) to emptyList() } else { val target = arguments.last() repo.copy(files = repo.files.map { if (it.name == target) it.copy(staged = false) else it }) to emptyList() } } private fun merge(repo: RepoState, arguments: List): Pair> { val branch = arguments.lastOrNull().orEmpty() val squash = "--squash" in arguments val files = when { branch == "feature" && repo.files.none { it.name == "file2" } -> repo.files + GitFile("file2", tracked = true) branch == "long-feature-branch" && repo.files.none { it.name == "file3" } -> repo.files + GitFile("file3", staged = true) branch == "mybranch" -> repo.files.map { if (it.name == "poem.txt") it.copy(content = "Humpty Dumpty sat on a wall\nHumpty Dumpty had a great fall", staged = true) else it } else -> repo.files } return repo.copy( files = files, maintenanceActions = repo.maintenanceActions + if (squash) "merge-squash" else "merge", ) to emptyList() } private fun rebase(repo: RepoState, arguments: List): Pair> { val interactive = "-i" in arguments || "--interactive" in arguments val originalCommits = repo.commits val commits = if (interactive && repo.commits.size > 2) { repo.commits .filterNot { it.message.contains("squash this commit", ignoreCase = true) } .map { if (it.message == "First coommit") it.copy(message = "First commit") else it } .let { ordered -> if (ordered.map { it.message }.containsAll(listOf("First commit", "Second commit", "Third commit"))) { ordered.sortedBy { commit -> when (commit.message) { "First commit" -> 1 "Second commit" -> 2 "Third commit" -> 3 else -> 0 } } } else { ordered } } } else { repo.commits } val updatedBranches = when { "--onto" in arguments -> repo.branches + (repo.headBranch to (repo.branches["master"] ?: 0) + 1) arguments.isNotEmpty() -> repo.branches + (repo.headBranch to maxOf(repo.branches[repo.headBranch] ?: 0, repo.branches[arguments.last()] ?: 0)) else -> repo.branches } val maintenanceActions = if ("--onto" in arguments) { repo.maintenanceActions + "rebase-onto" } else { repo.maintenanceActions } val output = if (interactive) interactiveRebaseConsoleLines(originalCommits, commits, arguments, repo.headBranch) else emptyList() return repo.copy(commits = commits, branches = updatedBranches, maintenanceActions = maintenanceActions) to output } private fun interactiveRebaseConsoleLines(originalCommits: List, rebasedCommits: List, arguments: List, branch: String): List { val target = arguments.lastOrNull { it != "-i" && it != "--interactive" }.orEmpty() val rebasedIds = rebasedCommits.map { it.id }.toSet() return buildList { originalCommits.forEach { commit -> val action = when { commit.id !in rebasedIds -> "squash" commit.message == "First coommit" -> "reword" else -> "pick" } add("$action ${commit.id} ${commit.message}") } add("") add("# Rebase ${target.ifBlank { "HEAD" }} in progress; onto HEAD") add("# Commands:") add("# p, pick = use commit") add("# r, reword = use commit, but edit the commit message") add("# s, squash = use commit, but meld into previous commit") add("# d, drop = remove commit") add("Successfully rebased and updated refs/heads/$branch.") } } private fun commit(repo: RepoState, arguments: List): Pair> { val parsed = parseCommitArguments(arguments) if (parsed.error != null) { return repo to listOf(parsed.error) } val repoForCommit = if (parsed.stageAllTracked) { repo.copy(files = repo.files.map { file -> if (file.tracked) file.copy(staged = true) else file }) } else { repo } val message = parsed.message ?: repo.commits.lastOrNull()?.message.orEmpty() val staged = repoForCommit.files.filter { it.staged } if (staged.isEmpty()) return repo to listOf("nothing to commit") val cleanedFiles = repoForCommit.files.map { file -> if (file.staged) file.copy(staged = false, tracked = true) else file } val nextCommits = if (parsed.amend && repo.commits.isNotEmpty()) { repo.commits.dropLast(1) + repo.commits.last().copy(message = message) } else { val nextId = "${repo.commits.size + 1}".padStart(7, '0') repo.commits + CommitNode(nextId, message) } return repoForCommit.copy( files = cleanedFiles, commits = nextCommits, branches = repo.branches + (repo.headBranch to nextCommits.size), ) to listOf("[${nextCommits.lastOrNull()?.id.orEmpty()}] $message") } private fun parseCommitArguments(arguments: List): ParsedCommitArguments { var message: String? = null var stageAllTracked = false var amend = false var index = 0 while (index < arguments.size) { val argument = arguments[index] when { argument == "-a" || argument == "--all" -> { stageAllTracked = true } argument == "--amend" -> { amend = true } argument == "--no-edit" -> { // Keep the previous commit message when amending. } argument == "--date" -> { if (arguments.getOrNull(index + 1) == null) { return ParsedCommitArguments(error = "error: option '--date' requires a value") } index += 1 } argument.startsWith("--date=") -> { // The sandbox records commit structure, not timestamps. } argument == "-m" || argument == "--message" -> { val next = arguments.getOrNull(index + 1) ?: return ParsedCommitArguments(error = "error: commit message required. Use git commit -m \"message\"") message = next index += 1 } argument.startsWith("--message=") -> { message = argument.substringAfter('=') } argument.startsWith("-") && !argument.startsWith("--") && argument.length > 2 -> { val shortFlags = argument.drop(1) var shortIndex = 0 while (shortIndex < shortFlags.length) { when (val flag = shortFlags[shortIndex]) { 'a' -> stageAllTracked = true 'm' -> { val attachedValue = shortFlags.substring(shortIndex + 1) if (attachedValue.isNotEmpty()) { message = attachedValue } else { val next = arguments.getOrNull(index + 1) ?: return ParsedCommitArguments(error = "error: commit message required. Use git commit -m \"message\"") message = next index += 1 } break } else -> return ParsedCommitArguments(error = "error: unsupported commit option '-$flag'") } shortIndex += 1 } } else -> return ParsedCommitArguments(error = "error: unsupported commit argument '$argument'") } index += 1 } if (!amend && message.isNullOrBlank()) { return ParsedCommitArguments(error = "error: commit message required. Use git commit -m \"message\"") } return ParsedCommitArguments(message = message, stageAllTracked = stageAllTracked, amend = amend) } private fun statusLines(repo: RepoState): List { val staged = repo.files.filter { it.staged }.map { when { it.deleted -> "deleted: ${it.name}" it.tracked -> "modified: ${it.name}" else -> "new file: ${it.name}" } } val deleted = repo.files.filter { it.deleted && it.tracked && !it.staged }.map { "deleted: ${it.name}" } val unstaged = repo.files.filterNot { it.staged || it.tracked || it.deleted }.map { "untracked: ${it.name}" } return buildList { add("On branch ${repo.headBranch}") if (staged.isEmpty() && deleted.isEmpty() && unstaged.isEmpty()) { add("nothing to commit, working tree clean") } else { if (staged.isNotEmpty()) { add("Changes to be committed:") addAll(staged) } if (deleted.isNotEmpty()) { add("Changes not staged for commit:") addAll(deleted) } if (unstaged.isNotEmpty()) { add("Untracked files:") addAll(unstaged) } } } } private fun String.hasGlob(): Boolean = any { it == '*' || it == '?' } private fun String.globToRegex(): Regex { val pattern = buildString { append('^') this@globToRegex.forEach { char -> when (char) { '*' -> append("[^/]*") '?' -> append("[^/]") '.', '(', ')', '+', '|', '^', '$', '@', '%', '{', '}', '[', ']', '\\' -> { append('\\') append(char) } else -> append(char) } } append('$') } return Regex(pattern) } private data class ParsedCommitArguments( val message: String? = null, val stageAllTracked: Boolean = false, val amend: Boolean = false, val error: String? = null, ) }