From ff816c3f26807ae85202af59524c3bfca1fe49d2 Mon Sep 17 00:00:00 2001 From: Zac White Date: Sat, 28 Mar 2026 14:59:22 -0700 Subject: [PATCH 1/2] Enable Python in BashEvalRunner --- Package.swift | 1 + Sources/BashEvalRunner/main.swift | 3 +++ 2 files changed, 4 insertions(+) diff --git a/Package.swift b/Package.swift index b938028..67f3c55 100644 --- a/Package.swift +++ b/Package.swift @@ -111,6 +111,7 @@ let package = Package( name: "BashEvalRunner", dependencies: [ "Bash", + "BashPython", .product(name: "ArgumentParser", package: "swift-argument-parser"), .product(name: "Yams", package: "Yams"), ] diff --git a/Sources/BashEvalRunner/main.swift b/Sources/BashEvalRunner/main.swift index 7948f3a..6980beb 100644 --- a/Sources/BashEvalRunner/main.swift +++ b/Sources/BashEvalRunner/main.swift @@ -1,5 +1,6 @@ import ArgumentParser import Bash +import BashPython import Foundation import Yams @@ -234,6 +235,8 @@ final class BashSwiftEngine: CandidateEngine { initialEnvironment: [Self.pwdHostRootEnvKey: effectiveHostRoot] ) ) + await BashPython.setCPythonRuntime() + await session.registerPython() self.maxOutputBytes = maxOutputBytes } From 8f5550b0a2ea5aebb47e01eaae2a46b5f738fffd Mon Sep 17 00:00:00 2001 From: Zac White Date: Sat, 28 Mar 2026 15:24:39 -0700 Subject: [PATCH 2/2] Add nl command and expand git and rg support --- Package.swift | 1 + Sources/Bash/Commands/DefaultCommands.swift | 1 + Sources/Bash/Commands/Text/LineCommands.swift | 99 ++++ .../Bash/Commands/Text/SearchCommands.swift | 237 ++++++-- Sources/BashEvalRunner/main.swift | 2 + Sources/BashGit/GitEngine.swift | 507 +++++++++++++++++- Tests/BashGitTests/GitCommandTests.swift | 114 ++++ Tests/BashTests/CommandCoverageTests.swift | 2 +- Tests/BashTests/RgAndNlParityTests.swift | 65 +++ 9 files changed, 954 insertions(+), 74 deletions(-) create mode 100644 Tests/BashTests/RgAndNlParityTests.swift diff --git a/Package.swift b/Package.swift index 67f3c55..126f65f 100644 --- a/Package.swift +++ b/Package.swift @@ -111,6 +111,7 @@ let package = Package( name: "BashEvalRunner", dependencies: [ "Bash", + "BashGit", "BashPython", .product(name: "ArgumentParser", package: "swift-argument-parser"), .product(name: "Yams", package: "Yams"), diff --git a/Sources/Bash/Commands/DefaultCommands.swift b/Sources/Bash/Commands/DefaultCommands.swift index 81ac161..31bc7f3 100644 --- a/Sources/Bash/Commands/DefaultCommands.swift +++ b/Sources/Bash/Commands/DefaultCommands.swift @@ -22,6 +22,7 @@ extension BashSession { RgCommand.self, HeadCommand.self, TailCommand.self, + NlCommand.self, WcCommand.self, SortCommand.self, UniqCommand.self, diff --git a/Sources/Bash/Commands/Text/LineCommands.swift b/Sources/Bash/Commands/Text/LineCommands.swift index 46e8e93..e690683 100644 --- a/Sources/Bash/Commands/Text/LineCommands.swift +++ b/Sources/Bash/Commands/Text/LineCommands.swift @@ -257,6 +257,70 @@ struct WcCommand: BuiltinCommand { } } +struct NlCommand: BuiltinCommand { + struct Options: ParsableArguments { + @Option(name: .short, help: "Select numbering style") + var b: String = "t" + + @Argument(help: "Optional files") + var files: [String] = [] + } + + static let name = "nl" + static let overview = "Number lines of files" + + static func _toAnyBuiltinCommand() -> AnyBuiltinCommand { + makeNormalizedLineCommand(Self.self) { args in + normalizeNlArguments(args) + } + } + + static func run(context: inout CommandContext, options: Options) async -> Int32 { + guard let mode = NumberingMode(rawValue: options.b) else { + context.writeStderr("nl: unsupported -b style '\(options.b)'\n") + return 1 + } + + let inputs = await CommandFS.readInputs(paths: options.files, context: &context) + for content in inputs.contents { + writeNumbered(content: content, mode: mode, context: &context) + } + return inputs.hadError ? 1 : 0 + } + + private enum NumberingMode: String { + case all = "a" + case nonEmpty = "t" + + func includes(_ line: String) -> Bool { + switch self { + case .all: + return true + case .nonEmpty: + return !line.isEmpty + } + } + } + + private static func writeNumbered( + content: String, + mode: NumberingMode, + context: inout CommandContext + ) { + let lines = CommandIO.splitLines(content) + var lineNumber = 1 + + for line in lines { + if mode.includes(line) { + context.writeStdout(String(format: "%6d\t%@\n", lineNumber, line)) + lineNumber += 1 + } else { + context.writeStdout(" \t\(line)\n") + } + } + } +} + private func shouldShowHeader(totalFiles: Int, quiet: Bool, verbose: Bool) -> Bool { if quiet { return false @@ -364,6 +428,41 @@ private func normalizeAttachedValueOption( return value.isEmpty ? nil : value } +private func normalizeNlArguments(_ args: [String]) -> [String] { + var normalized: [String] = [] + var passthrough = false + var expectsValue = false + + for arg in args { + if passthrough { + normalized.append(arg) + continue + } + + if arg == "--" { + passthrough = true + normalized.append(arg) + continue + } + + if expectsValue { + normalized.append(arg) + expectsValue = false + continue + } + + if let value = normalizeAttachedValueOption(arg, option: "b") { + normalized.append(contentsOf: ["-b", value]) + continue + } + + normalized.append(arg) + expectsValue = arg == "-b" || arg == "--b" + } + + return normalized +} + private func normalizeLegacyBareLineCount( _ arg: String, command: LegacyLineCountCommand diff --git a/Sources/Bash/Commands/Text/SearchCommands.swift b/Sources/Bash/Commands/Text/SearchCommands.swift index 74c7b49..e5d7edc 100644 --- a/Sources/Bash/Commands/Text/SearchCommands.swift +++ b/Sources/Bash/Commands/Text/SearchCommands.swift @@ -380,6 +380,7 @@ struct RgCommand: BuiltinCommand { let patterns: [String] let roots: [String] + let hasExplicitRoots: Bool if options.files { if !options.e.isEmpty || !options.f.isEmpty { context.writeStderr("rg: cannot combine --files with -e or -f\n") @@ -387,6 +388,7 @@ struct RgCommand: BuiltinCommand { } patterns = [] roots = options.values.isEmpty ? ["."] : options.values + hasExplicitRoots = !options.values.isEmpty } else { var resolvedPatterns = options.e let patternFileRead = await readPatternsFromFiles(paths: options.f, commandName: "rg", context: &context) @@ -402,8 +404,10 @@ struct RgCommand: BuiltinCommand { } resolvedPatterns.append(rawPattern) roots = options.values.count > 1 ? Array(options.values.dropFirst()) : ["."] + hasExplicitRoots = options.values.count > 1 } else { roots = options.values.isEmpty ? ["."] : options.values + hasExplicitRoots = !options.values.isEmpty } guard !resolvedPatterns.isEmpty else { @@ -415,24 +419,80 @@ struct RgCommand: BuiltinCommand { let includeExtensions = resolvedTypeExtensions(options.t) let excludeExtensions = resolvedTypeExtensions(options.T) + let readFromStandardInput = !options.files && !hasExplicitRoots && !context.stdin.isEmpty + + if !readFromStandardInput { + let candidate = await collectCandidateFiles( + roots: roots, + includeHidden: options.hidden || options.noIgnore, + globs: options.globs, + includeExtensions: includeExtensions, + excludeExtensions: excludeExtensions, + relativeDisplayPaths: options.files, + context: &context + ) + if candidate.hadError { + return 2 + } - let candidate = await collectCandidateFiles( - roots: roots, - includeHidden: options.hidden || options.noIgnore, - globs: options.globs, - includeExtensions: includeExtensions, - excludeExtensions: excludeExtensions, - context: &context - ) - if candidate.hadError { - return 2 - } + if options.files { + for file in candidate.files { + context.writeStdout("\(file.displayPath)\n") + } + return 0 + } - if options.files { - for file in candidate.files { - context.writeStdout("\(file.displayPath)\n") + guard !patterns.isEmpty else { + return 2 + } + + let ignoreCase = options.i || (options.S && !containsUppercase(in: patterns)) + let matcher: SearchMatcher + do { + matcher = try SearchMatcher.make( + commandName: "rg", + patterns: patterns, + fixedStrings: options.F, + ignoreCase: ignoreCase, + wordMatch: options.w, + fullLineMatch: options.x + ) + } catch let error as SearchMatcherBuildError { + context.writeStderr("\(error.message)\n") + return 2 + } catch { + context.writeStderr("rg: failed to build matcher\n") + return 2 + } + + var foundMatch = false + var hadError = false + + for candidateFile in candidate.files { + do { + let matched = try await searchFile( + path: candidateFile.path, + displayPath: candidateFile.displayPath, + matcher: matcher, + includeLineNumbers: options.n, + fileNamesOnly: options.l, + countOnly: options.c, + beforeContext: beforeContext, + afterContext: afterContext, + maxMatchesPerFile: options.m, + context: &context + ) + foundMatch = foundMatch || matched + } catch { + context.writeStderr("rg: \(candidateFile.displayPath): \(error)\n") + hadError = true + } + } + + if hadError { + return 2 } - return 0 + return foundMatch ? 0 : 1 } guard !patterns.isEmpty else { @@ -457,35 +517,19 @@ struct RgCommand: BuiltinCommand { context.writeStderr("rg: failed to build matcher\n") return 2 } - - var foundMatch = false - var hadError = false - - for candidateFile in candidate.files { - do { - let matched = try await searchFile( - path: candidateFile.path, - displayPath: candidateFile.displayPath, - matcher: matcher, - includeLineNumbers: options.n, - fileNamesOnly: options.l, - countOnly: options.c, - beforeContext: beforeContext, - afterContext: afterContext, - maxMatchesPerFile: options.m, - context: &context - ) - foundMatch = foundMatch || matched - } catch { - context.writeStderr("rg: \(candidateFile.displayPath): \(error)\n") - hadError = true - } - } - - if hadError { - return 2 - } - return foundMatch ? 0 : 1 + let matched = searchContent( + content: CommandIO.decodeString(context.stdin), + displayPath: "", + matcher: matcher, + includeLineNumbers: options.n, + fileNamesOnly: options.l, + countOnly: options.c, + beforeContext: beforeContext, + afterContext: afterContext, + maxMatchesPerFile: options.m, + context: &context + ) + return matched ? 0 : 1 } private struct CandidateFile { @@ -507,6 +551,32 @@ struct RgCommand: BuiltinCommand { ) async throws -> Bool { let data = try await context.filesystem.readFile(path: path) let content = CommandIO.decodeString(data) + return searchContent( + content: content, + displayPath: displayPath, + matcher: matcher, + includeLineNumbers: includeLineNumbers, + fileNamesOnly: fileNamesOnly, + countOnly: countOnly, + beforeContext: beforeContext, + afterContext: afterContext, + maxMatchesPerFile: maxMatchesPerFile, + context: &context + ) + } + + private static func searchContent( + content: String, + displayPath: String, + matcher: SearchMatcher, + includeLineNumbers: Bool, + fileNamesOnly: Bool, + countOnly: Bool, + beforeContext: Int, + afterContext: Int, + maxMatchesPerFile: Int?, + context: inout CommandContext + ) -> Bool { let lines = CommandIO.splitLines(content) var matchedIndices: [Int] = [] @@ -522,12 +592,16 @@ struct RgCommand: BuiltinCommand { } if fileNamesOnly { - context.writeStdout("\(displayPath)\n") + context.writeStdout(displayPath.isEmpty ? "(standard input)\n" : "\(displayPath)\n") return true } if countOnly { - context.writeStdout("\(displayPath):\(matchedIndices.count)\n") + if displayPath.isEmpty { + context.writeStdout("\(matchedIndices.count)\n") + } else { + context.writeStdout("\(displayPath):\(matchedIndices.count)\n") + } return true } @@ -542,11 +616,20 @@ struct RgCommand: BuiltinCommand { for index in outputIndices { let line = lines[index] if includeLineNumbers { - context.writeStdout("\(displayPath):\(index + 1):\(line)\n") + let prefix = displayPath.isEmpty ? "\(index + 1):" : "\(displayPath):\(index + 1):" + context.writeStdout("\(prefix)\(line)\n") } else if matchSet.contains(index) { - context.writeStdout("\(displayPath):\(line)\n") + if displayPath.isEmpty { + context.writeStdout("\(line)\n") + } else { + context.writeStdout("\(displayPath):\(line)\n") + } } else { - context.writeStdout("\(displayPath)-\(line)\n") + if displayPath.isEmpty { + context.writeStdout("\(line)\n") + } else { + context.writeStdout("\(displayPath)-\(line)\n") + } } } @@ -571,11 +654,13 @@ struct RgCommand: BuiltinCommand { globs: [String], includeExtensions: Set, excludeExtensions: Set, + relativeDisplayPaths: Bool, context: inout CommandContext ) async -> (files: [CandidateFile], hadError: Bool) { var result: [CandidateFile] = [] var seen = Set() var hadError = false + let currentDirectory = context.currentDirectoryPath let globRegexes: [NSRegularExpression] = globs.compactMap { glob in try? NSRegularExpression(pattern: WorkspacePath.globToRegex(glob)) @@ -606,7 +691,15 @@ struct RgCommand: BuiltinCommand { continue } if seen.insert(entry.string).inserted { - result.append(CandidateFile(path: entry, displayPath: entry.string)) + let displayPath = relativeDisplayPaths + ? candidateDisplayPath( + path: entry, + resolvedRoot: resolved, + rootArgument: root, + currentDirectory: currentDirectory + ) + : entry.string + result.append(CandidateFile(path: entry, displayPath: displayPath)) } } } else { @@ -624,7 +717,15 @@ struct RgCommand: BuiltinCommand { continue } if seen.insert(resolved.string).inserted { - result.append(CandidateFile(path: resolved, displayPath: root)) + let displayPath = relativeDisplayPaths + ? candidateDisplayPath( + path: resolved, + resolvedRoot: resolved, + rootArgument: root, + currentDirectory: currentDirectory + ) + : root + result.append(CandidateFile(path: resolved, displayPath: displayPath)) } } } catch { @@ -636,6 +737,40 @@ struct RgCommand: BuiltinCommand { return (result.sorted { $0.displayPath < $1.displayPath }, hadError) } + private static func candidateDisplayPath( + path: WorkspacePath, + resolvedRoot: WorkspacePath, + rootArgument: String, + currentDirectory: WorkspacePath + ) -> String { + if rootArgument.hasPrefix("/") { + return path.string + } + + let relativeRoot = relativeChildPath(path: resolvedRoot, root: currentDirectory) ?? resolvedRoot.string + let relativePath = relativeChildPath(path: path, root: resolvedRoot) ?? path.basename + + if relativePath == "." { + return relativeRoot.isEmpty ? path.basename : relativeRoot + } + if relativeRoot.isEmpty || relativeRoot == "." { + return relativePath + } + return "\(relativeRoot)/\(relativePath)" + } + + private static func relativeChildPath(path: WorkspacePath, root: WorkspacePath) -> String? { + if path == root { + return "." + } + + let prefix = root.isRoot ? "/" : root.string + "/" + guard path.string.hasPrefix(prefix) else { + return nil + } + return String(path.string.dropFirst(prefix.count)) + } + private static func matchesGlobs(path: String, globs: [NSRegularExpression]) -> Bool { guard !globs.isEmpty else { return true diff --git a/Sources/BashEvalRunner/main.swift b/Sources/BashEvalRunner/main.swift index 6980beb..c17523b 100644 --- a/Sources/BashEvalRunner/main.swift +++ b/Sources/BashEvalRunner/main.swift @@ -1,5 +1,6 @@ import ArgumentParser import Bash +import BashGit import BashPython import Foundation import Yams @@ -236,6 +237,7 @@ final class BashSwiftEngine: CandidateEngine { ) ) await BashPython.setCPythonRuntime() + await session.registerGit() await session.registerPython() self.maxOutputBytes = maxOutputBytes } diff --git a/Sources/BashGit/GitEngine.swift b/Sources/BashGit/GitEngine.swift index 5079bba..3ed7613 100644 --- a/Sources/BashGit/GitEngine.swift +++ b/Sources/BashGit/GitEngine.swift @@ -89,12 +89,27 @@ private enum GitEngineLibgit2 { case "status": return try await runStatus(arguments: remaining, context: &context) + case "diff": + return try await runDiff(arguments: remaining, context: &context) + + case "show": + return try await runShow(arguments: remaining, context: &context) + case "add": return try await runAdd(arguments: remaining, context: &context) + case "branch": + return try await runBranch(arguments: remaining, context: &context) + + case "remote": + return try await runRemote(arguments: remaining, context: &context) + case "commit": return try await runCommit(arguments: remaining, context: &context) + case "config": + return try await runConfig(arguments: remaining, context: &context) + case "log": return try await runLog(arguments: remaining, context: &context) @@ -128,11 +143,16 @@ private enum GitEngineLibgit2 { SUBCOMMANDS: init [path] clone [directory] - status [--short] + status [--short] [--branch] + diff [--stat|--name-only] + show --stat add [-A|--all] + branch --show-current + remote [-v] commit -m + config [value] log [--oneline] [-n ] - rev-parse --is-inside-work-tree + rev-parse <--is-inside-work-tree|--abbrev-ref HEAD> version """ @@ -224,12 +244,18 @@ private enum GitEngineLibgit2 { private static func runStatus(arguments: [String], context: inout CommandContext) async throws -> GitExecutionResult { var short = false + var branch = false for argument in arguments { switch argument { case "--short", "-s": short = true + case "--branch", "-b": + branch = true + case "-sb", "-bs": + short = true + branch = true default: - throw GitEngineError.usage("usage: git status [--short]\n") + throw GitEngineError.usage("usage: git status [--short] [--branch]\n") } } @@ -237,7 +263,68 @@ private enum GitEngineLibgit2 { defer { projection.cleanup() } let output = try withLibgit2 { - try statusOutput(localRoot: projection.localRoot, short: short) + try statusOutput(localRoot: projection.localRoot, short: short, branch: branch) + } + + return GitExecutionResult(stdout: output, exitCode: 0) + } + + private static func runDiff(arguments: [String], context: inout CommandContext) async throws -> GitExecutionResult { + var stat = false + var nameOnly = false + + for argument in arguments { + switch argument { + case "--stat": + stat = true + case "--name-only": + nameOnly = true + default: + throw GitEngineError.usage("usage: git diff [--stat|--name-only]\n") + } + } + + if stat == nameOnly { + throw GitEngineError.usage("usage: git diff [--stat|--name-only]\n") + } + + let projection = try await requireRepositoryProjection(context: context) + defer { projection.cleanup() } + + let output = try withLibgit2 { + let repository = try openRepository(path: projection.localRoot.path) + defer { git_repository_free(repository) } + + let diff = try makeWorktreeDiff(repository: repository) + defer { git_diff_free(diff) } + + if stat { + return try diffStatOutput(diff: diff) + } + return diffNameOnlyOutput(diff: diff) + } + + return GitExecutionResult(stdout: output, exitCode: 0) + } + + private static func runShow(arguments: [String], context: inout CommandContext) async throws -> GitExecutionResult { + guard arguments == ["--stat"] else { + throw GitEngineError.usage("usage: git show --stat\n") + } + + let projection = try await requireRepositoryProjection(context: context) + defer { projection.cleanup() } + + let output = try withLibgit2 { + let repository = try openRepository(path: projection.localRoot.path) + defer { git_repository_free(repository) } + + guard let commit = try lookupHeadCommit(repository: repository) else { + throw GitEngineError.runtime("your current branch does not have any commits yet") + } + defer { git_commit_free(commit) } + + return try showStatOutput(repository: repository, commit: commit) } return GitExecutionResult(stdout: output, exitCode: 0) @@ -306,6 +393,46 @@ private enum GitEngineLibgit2 { return GitExecutionResult(exitCode: 0) } + private static func runBranch(arguments: [String], context: inout CommandContext) async throws -> GitExecutionResult { + guard arguments == ["--show-current"] else { + throw GitEngineError.usage("usage: git branch --show-current\n") + } + + let projection = try await requireRepositoryProjection(context: context) + defer { projection.cleanup() } + + let output = try withLibgit2 { + let repository = try openRepository(path: projection.localRoot.path) + defer { git_repository_free(repository) } + return try branchNameForDisplay(repository: repository).map { "\($0)\n" } ?? "" + } + + return GitExecutionResult(stdout: output, exitCode: 0) + } + + private static func runRemote(arguments: [String], context: inout CommandContext) async throws -> GitExecutionResult { + var verbose = false + for argument in arguments { + switch argument { + case "-v", "--verbose": + verbose = true + default: + throw GitEngineError.usage("usage: git remote [-v]\n") + } + } + + let projection = try await requireRepositoryProjection(context: context) + defer { projection.cleanup() } + + let output = try withLibgit2 { + let repository = try openRepository(path: projection.localRoot.path) + defer { git_repository_free(repository) } + return try remoteOutput(repository: repository, verbose: verbose) + } + + return GitExecutionResult(stdout: output, exitCode: 0) + } + private static func runCommit(arguments: [String], context: inout CommandContext) async throws -> GitExecutionResult { let message = try parseCommitMessage(arguments) let projection = try await requireRepositoryProjection(context: context) @@ -348,7 +475,7 @@ private enum GitEngineLibgit2 { } } - let signature = try createSignature(environment: context.environment) + let signature = try createSignature(repository: repository, environment: context.environment) defer { git_signature_free(signature) } var commitOID = git_oid() @@ -435,23 +562,70 @@ private enum GitEngineLibgit2 { return GitExecutionResult(stdout: output, exitCode: 0) } - private static func runRevParse(arguments: [String], context: inout CommandContext) async throws -> GitExecutionResult { - guard arguments == ["--is-inside-work-tree"] else { - throw GitEngineError.usage("usage: git rev-parse --is-inside-work-tree\n") + private static func runConfig(arguments: [String], context: inout CommandContext) async throws -> GitExecutionResult { + guard arguments.count == 1 || arguments.count == 2 else { + throw GitEngineError.usage("usage: git config [value]\n") } + let key = arguments[0] + guard key == "user.name" || key == "user.email" else { + throw GitEngineError.usage("usage: git config [value]\n") + } + + let projection = try await requireRepositoryProjection(context: context) + defer { projection.cleanup() } + + let result = try withLibgit2 { + let repository = try openRepository(path: projection.localRoot.path) + defer { git_repository_free(repository) } + + if arguments.count == 2 { + try setConfigValue(repository: repository, key: key, value: arguments[1]) + return GitExecutionResult(exitCode: 0) + } + + guard let value = try configValue(repository: repository, key: key) else { + return GitExecutionResult(exitCode: 1) + } + return GitExecutionResult(stdout: "\(value)\n", exitCode: 0) + } + + if arguments.count == 2, result.exitCode == 0 { + try await projection.syncBack(filesystem: context.filesystem) + } + return result + } + + private static func runRevParse(arguments: [String], context: inout CommandContext) async throws -> GitExecutionResult { let start = WorkspacePath(normalizing: context.currentDirectory) - if let _ = try await GitFilesystemProjection.findRepositoryRoot( + guard let _ = try await GitFilesystemProjection.findRepositoryRoot( from: start, filesystem: context.filesystem - ) { - return GitExecutionResult(stdout: "true\n", exitCode: 0) + ) else { + return GitExecutionResult( + stderr: "fatal: not a git repository (or any of the parent directories): .git\n", + exitCode: 128 + ) } - return GitExecutionResult( - stderr: "fatal: not a git repository (or any of the parent directories): .git\n", - exitCode: 128 - ) + switch arguments { + case ["--is-inside-work-tree"]: + return GitExecutionResult(stdout: "true\n", exitCode: 0) + + case ["--abbrev-ref", "HEAD"]: + let projection = try await requireRepositoryProjection(context: context) + defer { projection.cleanup() } + let output = try withLibgit2 { + let repository = try openRepository(path: projection.localRoot.path) + defer { git_repository_free(repository) } + let branch = try branchNameForDisplay(repository: repository) ?? "HEAD" + return "\(branch)\n" + } + return GitExecutionResult(stdout: output, exitCode: 0) + + default: + throw GitEngineError.usage("usage: git rev-parse <--is-inside-work-tree|--abbrev-ref HEAD>\n") + } } private static func runVersion(arguments: [String]) throws -> GitExecutionResult { @@ -649,7 +823,7 @@ private enum GitEngineLibgit2 { return message } - private static func statusOutput(localRoot: URL, short: Bool) throws -> String { + private static func statusOutput(localRoot: URL, short: Bool, branch: Bool) throws -> String { let repository = try openRepository(path: localRoot.path) defer { git_repository_free(repository) } @@ -685,10 +859,15 @@ private enum GitEngineLibgit2 { lines.sort() if short { - if lines.isEmpty { - return "" + var output = "" + if branch { + let branchName = try branchNameForDisplay(repository: repository) ?? "HEAD" + output += "## \(branchName)\n" } - return lines.joined(separator: "\n") + "\n" + if !lines.isEmpty { + output += lines.joined(separator: "\n") + "\n" + } + return output } let branch = (try? currentBranchName(repository: repository)) ?? "HEAD" @@ -754,6 +933,157 @@ private enum GitEngineLibgit2 { return output } + private static func makeWorktreeDiff(repository: OpaquePointer) throws -> OpaquePointer { + var indexPointer: OpaquePointer? + try check(git_repository_index(&indexPointer, repository), action: "open repository index") + guard let indexPointer else { + throw GitEngineError.runtime("failed to open repository index") + } + defer { git_index_free(indexPointer) } + + var options = git_diff_options() + try check(git_diff_options_init(&options, UInt32(GIT_DIFF_OPTIONS_VERSION)), action: "initialize diff options") + + var diffPointer: OpaquePointer? + try check( + git_diff_index_to_workdir(&diffPointer, repository, indexPointer, &options), + action: "collect worktree diff" + ) + guard let diffPointer else { + throw GitEngineError.runtime("failed to collect worktree diff") + } + return diffPointer + } + + private static func diffNameOnlyOutput(diff: OpaquePointer) -> String { + let count = git_diff_num_deltas(diff) + var paths: [String] = [] + paths.reserveCapacity(count) + + for index in 0.. String { + let count = git_diff_num_deltas(diff) + guard count > 0 else { + return "" + } + + var lines: [String] = [] + lines.reserveCapacity(count) + var filesChanged = 0 + var insertions = 0 + var deletions = 0 + + for index in 0.. 0 { + summaryParts.append("\(insertions) insertion" + (insertions == 1 ? "" : "s") + "(+)") + } + if deletions > 0 { + summaryParts.append("\(deletions) deletion" + (deletions == 1 ? "" : "s") + "(-)") + } + + return lines.joined(separator: "\n") + "\n " + summaryParts.joined(separator: ", ") + "\n" + } + + private static func showStatOutput(repository: OpaquePointer, commit: OpaquePointer) throws -> String { + var treePointer: OpaquePointer? + try check(git_commit_tree(&treePointer, commit), action: "read commit tree") + guard let treePointer else { + throw GitEngineError.runtime("failed to read commit tree") + } + defer { git_tree_free(treePointer) } + + var parentTreePointer: OpaquePointer? + if git_commit_parentcount(commit) > 0 { + var parentCommitPointer: OpaquePointer? + try check(git_commit_parent(&parentCommitPointer, commit, 0), action: "read parent commit") + if let parentCommitPointer { + defer { git_commit_free(parentCommitPointer) } + try check(git_commit_tree(&parentTreePointer, parentCommitPointer), action: "read parent tree") + } + } + var diffOptions = git_diff_options() + try check(git_diff_options_init(&diffOptions, UInt32(GIT_DIFF_OPTIONS_VERSION)), action: "initialize diff options") + + var diffPointer: OpaquePointer? + try check( + git_diff_tree_to_tree(&diffPointer, repository, parentTreePointer, treePointer, &diffOptions), + action: "collect commit diff" + ) + guard let diffPointer else { + throw GitEngineError.runtime("failed to collect commit diff") + } + defer { git_diff_free(diffPointer) } + if let parentTreePointer { + git_tree_free(parentTreePointer) + } + + let subject = firstLine(of: git_commit_message(commit).map { String(cString: $0) } ?? "") + let commitID = oidString(git_commit_id(commit).pointee) + let authorLine: String + if let author = git_commit_author(commit) { + let name = author.pointee.name.map { String(cString: $0) } ?? "unknown" + let email = author.pointee.email.map { String(cString: $0) } ?? "unknown" + authorLine = "Author: \(name) <\(email)>" + } else { + authorLine = "Author: unknown " + } + + let stats = try diffStatOutput(diff: diffPointer) + return "commit \(commitID)\n\(authorLine)\n\n \(subject)\n\n\(stats)" + } + private static func openRepository(path: String) throws -> OpaquePointer { var repository: OpaquePointer? try withCString(path: path) { cPath in @@ -767,6 +1097,10 @@ private enum GitEngineLibgit2 { } private static func currentBranchName(repository: OpaquePointer) throws -> String { + if let symbolicBranch = try symbolicHeadBranchName(repository: repository) { + return symbolicBranch + } + var reference: OpaquePointer? let code = git_repository_head(&reference, repository) if code == GIT_EUNBORNBRANCH.rawValue { @@ -783,6 +1117,18 @@ private enum GitEngineLibgit2 { return String(cString: shorthand) } + private static func branchNameForDisplay(repository: OpaquePointer) throws -> String? { + if let symbolicBranch = try symbolicHeadBranchName(repository: repository) { + return symbolicBranch + } + + let isDetached = git_repository_head_detached(repository) + if isDetached == 1 { + return nil + } + return try currentBranchName(repository: repository) + } + private static func lookupHeadCommit(repository: OpaquePointer) throws -> OpaquePointer? { var oid = git_oid() let oidCode = git_reference_name_to_id(&oid, repository, "HEAD") @@ -796,9 +1142,14 @@ private enum GitEngineLibgit2 { return commit } - private static func createSignature(environment: [String: String]) throws -> UnsafeMutablePointer { - let authorName = environment["GIT_AUTHOR_NAME"] ?? environment["USER"] ?? "user" - let authorEmail = environment["GIT_AUTHOR_EMAIL"] ?? "\(authorName)@example.com" + private static func createSignature( + repository: OpaquePointer, + environment: [String: String] + ) throws -> UnsafeMutablePointer { + let configuredName = try configValue(repository: repository, key: "user.name") + let configuredEmail = try configValue(repository: repository, key: "user.email") + let authorName = environment["GIT_AUTHOR_NAME"] ?? configuredName ?? environment["USER"] ?? "user" + let authorEmail = environment["GIT_AUTHOR_EMAIL"] ?? configuredEmail ?? "\(authorName)@example.com" var signature: UnsafeMutablePointer? try withCString(path: authorName) { name in @@ -813,6 +1164,31 @@ private enum GitEngineLibgit2 { return signature } + private static func symbolicHeadBranchName(repository: OpaquePointer) throws -> String? { + var reference: OpaquePointer? + let code = try withCString(path: "HEAD") { cName in + git_reference_lookup(&reference, repository, cName) + } + if code == GIT_ENOTFOUND.rawValue { + return nil + } + try check(code, action: "read HEAD reference") + guard let reference else { + return nil + } + defer { git_reference_free(reference) } + + if let target = git_reference_symbolic_target(reference) { + let targetName = String(cString: target) + let prefix = "refs/heads/" + if targetName.hasPrefix(prefix) { + return String(targetName.dropFirst(prefix.count)) + } + return targetName + } + return nil + } + private static func statusPath(entry: git_status_entry) -> String? { if let delta = entry.index_to_workdir ?? entry.head_to_index { if let path = delta.pointee.new_file.path ?? delta.pointee.old_file.path { @@ -852,6 +1228,93 @@ private enum GitEngineLibgit2 { return String([indexCode, worktreeCode]) } + private static func diffDeltaPath(delta: git_diff_delta) -> String? { + if let path = delta.new_file.path { + return String(cString: path) + } + if let path = delta.old_file.path { + return String(cString: path) + } + return nil + } + + private static func configValue(repository: OpaquePointer, key: String) throws -> String? { + var configPointer: OpaquePointer? + try check(git_repository_config(&configPointer, repository), action: "open repository config") + guard let configPointer else { + throw GitEngineError.runtime("failed to open repository config") + } + defer { git_config_free(configPointer) } + + var buffer = git_buf() + defer { git_buf_dispose(&buffer) } + + let code = try withCString(path: key) { cKey in + git_config_get_string_buf(&buffer, configPointer, cKey) + } + if code == GIT_ENOTFOUND.rawValue { + return nil + } + try check(code, action: "read git config") + guard let pointer = buffer.ptr else { + return nil + } + return String(cString: pointer) + } + + private static func setConfigValue(repository: OpaquePointer, key: String, value: String) throws { + var configPointer: OpaquePointer? + try check(git_repository_config(&configPointer, repository), action: "open repository config") + guard let configPointer else { + throw GitEngineError.runtime("failed to open repository config") + } + defer { git_config_free(configPointer) } + + try withCString(path: key) { cKey in + try withCString(path: value) { cValue in + try check(git_config_set_string(configPointer, cKey, cValue), action: "write git config") + } + } + } + + private static func remoteOutput(repository: OpaquePointer, verbose: Bool) throws -> String { + var remoteNames = git_strarray(strings: nil, count: 0) + try check(git_remote_list(&remoteNames, repository), action: "list remotes") + defer { git_strarray_dispose(&remoteNames) } + + guard remoteNames.count > 0 else { + return "" + } + + var lines: [String] = [] + for index in 0..(_ body: () throws -> T) throws -> T { git_libgit2_init() defer { git_libgit2_shutdown() } diff --git a/Tests/BashGitTests/GitCommandTests.swift b/Tests/BashGitTests/GitCommandTests.swift index 65867d9..059e9e5 100644 --- a/Tests/BashGitTests/GitCommandTests.swift +++ b/Tests/BashGitTests/GitCommandTests.swift @@ -72,6 +72,120 @@ struct GitCommandTests { #expect(clean.stdoutString.isEmpty) } + @Test("status -sb and branch inspection") + func statusShortBranchAndBranchInspection() async throws { + let (session, root) = try await GitTestSupport.makeReadWriteSession() + defer { GitTestSupport.removeDirectory(root) } + + _ = await session.run("git init") + _ = await session.run("printf 'one\\n' > tracked.txt") + _ = await session.run("git add tracked.txt") + _ = await session.run("printf 'two\\n' > tracked.txt") + _ = await session.run("printf 'new\\n' > untracked.txt") + + let status = await session.run("git status -sb") + #expect(status.exitCode == 0) + #expect(status.stdoutString.contains("## ")) + #expect(status.stdoutString.contains("tracked.txt")) + #expect(status.stdoutString.contains("untracked.txt")) + + let branch = await session.run("git branch --show-current") + #expect(branch.exitCode == 0) + #expect(!branch.stdoutString.isEmpty) + + let revParse = await session.run("git rev-parse --abbrev-ref HEAD") + #expect(revParse.exitCode == 0) + #expect(revParse.stdoutString == branch.stdoutString) + } + + @Test("diff stat and name-only inspect worktree changes") + func diffStatAndNameOnlyInspectWorktreeChanges() async throws { + let (session, root) = try await GitTestSupport.makeReadWriteSession() + defer { GitTestSupport.removeDirectory(root) } + + _ = await session.run("git init") + _ = await session.run("git config user.email eval@example.com") + _ = await session.run("git config user.name Eval") + _ = await session.run("printf 'alpha\\n' > README.md") + _ = await session.run("git add README.md") + _ = await session.run("git commit -m \"init\"") + _ = await session.run("printf 'beta\\n' >> README.md") + + let stat = await session.run("git diff --stat") + #expect(stat.exitCode == 0) + #expect(stat.stdoutString.contains("README.md")) + #expect(stat.stdoutString.contains("1 insertion(+)")) + + let names = await session.run("git diff --name-only") + #expect(names.exitCode == 0) + #expect(names.stdoutString == "README.md\n") + } + + @Test("show --stat and remote -v work for cloned repositories") + func showStatAndRemoteVerboseWorkForClonedRepositories() async throws { + let (session, root) = try await GitTestSupport.makeReadWriteSession() + defer { GitTestSupport.removeDirectory(root) } + + _ = await session.run("mkdir seed") + _ = await session.run("cd seed") + _ = await session.run("git init") + _ = await session.run("git config user.email eval@example.com") + _ = await session.run("git config user.name Eval") + _ = await session.run("echo hello > README.md") + _ = await session.run("git add README.md") + _ = await session.run("git commit -m \"seed\"") + _ = await session.run("cd ..") + + let clone = await session.run("git clone seed cloned") + #expect(clone.exitCode == 0) + + _ = await session.run("cd cloned") + + let remote = await session.run("git remote -v") + #expect(remote.exitCode == 0) + #expect(remote.stdoutString.contains("origin")) + #expect(remote.stdoutString.contains("(fetch)")) + #expect(remote.stdoutString.contains("(push)")) + + let show = await session.run("git show --stat") + #expect(show.exitCode == 0) + #expect(show.stdoutString.contains("seed")) + #expect(show.stdoutString.contains("README.md")) + #expect(show.stdoutString.contains("1 insertion(+)")) + } + + @Test("config persists locally and drives commit identity") + func configPersistsAndDrivesCommitIdentity() async throws { + let (session, root) = try await GitTestSupport.makeReadWriteSession() + defer { GitTestSupport.removeDirectory(root) } + + _ = await session.run("git init") + + let setEmail = await session.run("git config user.email eval@example.com") + #expect(setEmail.exitCode == 0) + + let setName = await session.run("git config user.name Eval") + #expect(setName.exitCode == 0) + + let getEmail = await session.run("git config user.email") + #expect(getEmail.exitCode == 0) + #expect(getEmail.stdoutString == "eval@example.com\n") + + let getName = await session.run("git config user.name") + #expect(getName.exitCode == 0) + #expect(getName.stdoutString == "Eval\n") + + _ = await session.run("printf 'hello\\n' > note.txt") + _ = await session.run("git add note.txt") + + let commit = await session.run("git commit -m \"configured\"") + #expect(commit.exitCode == 0) + + let log = await session.run("git log -n 1") + #expect(log.exitCode == 0) + #expect(log.stdoutString.contains("Author: Eval ")) + } + @Test("in-memory filesystem persists git metadata") func inMemoryPersistence() async throws { let session = try await GitTestSupport.makeInMemorySession() diff --git a/Tests/BashTests/CommandCoverageTests.swift b/Tests/BashTests/CommandCoverageTests.swift index f09f7a5..54c2966 100644 --- a/Tests/BashTests/CommandCoverageTests.swift +++ b/Tests/BashTests/CommandCoverageTests.swift @@ -6,7 +6,7 @@ import Testing struct CommandCoverageTests { private let commands = [ "cat", "cp", "ln", "ls", "mkdir", "mv", "readlink", "rm", "rmdir", "stat", "touch", "chmod", "file", "tree", "diff", - "grep", "egrep", "fgrep", "rg", "head", "tail", "wc", "sort", "uniq", "cut", "tr", "awk", "sed", "xargs", "printf", "base64", "sha256sum", "sha1sum", "md5sum", + "grep", "egrep", "fgrep", "rg", "head", "tail", "nl", "wc", "sort", "uniq", "cut", "tr", "awk", "sed", "xargs", "printf", "base64", "sha256sum", "sha1sum", "md5sum", "gzip", "gunzip", "zcat", "zip", "unzip", "tar", "jq", "yq", "xan", "basename", "cd", "dirname", "du", "echo", "env", "export", "find", "printenv", "pwd", "tee", diff --git a/Tests/BashTests/RgAndNlParityTests.swift b/Tests/BashTests/RgAndNlParityTests.swift new file mode 100644 index 0000000..fface46 --- /dev/null +++ b/Tests/BashTests/RgAndNlParityTests.swift @@ -0,0 +1,65 @@ +import Foundation +import Testing +@testable import Bash + +@Suite("Rg And Nl Parity") +struct RgAndNlParityTests { + @Test("rg --files emits paths relative to current directory") + func rgFilesUsesRelativePaths() async throws { + let (session, root) = try await TestSupport.makeSession() + defer { TestSupport.removeDirectory(root) } + + _ = await session.run("mkdir -p Sources/App Tests/AppTests Docs") + _ = await session.run("printf 'struct App {}\\n' > Sources/App/App.swift") + _ = await session.run("printf 'struct Helpers {}\\n' > Sources/App/Helpers.swift") + _ = await session.run("printf 'final class AppTests {}\\n' > Tests/AppTests/AppTests.swift") + _ = await session.run("printf '# ignore\\n' > Docs/readme.md") + + let result = await session.run("rg --files | rg '^(Sources|Tests)/.*\\.swift$' | sort") + #expect(result.exitCode == 0) + #expect( + result.stdoutString == + "Sources/App/App.swift\nSources/App/Helpers.swift\nTests/AppTests/AppTests.swift\n" + ) + } + + @Test("rg --files preserves explicit relative roots") + func rgFilesWithExplicitRoots() async throws { + let (session, root) = try await TestSupport.makeSession() + defer { TestSupport.removeDirectory(root) } + + _ = await session.run("mkdir -p Sources/Core Tests/Core Docs") + _ = await session.run("printf 'struct Core {}\\n' > Sources/Core/Core.swift") + _ = await session.run("printf 'final class CoreTests {}\\n' > Tests/Core/CoreTests.swift") + _ = await session.run("printf 'skip\\n' > Docs/info.txt") + + let result = await session.run("rg --files Sources Tests | sort") + #expect(result.exitCode == 0) + #expect(result.stdoutString == "Sources/Core/Core.swift\nTests/Core/CoreTests.swift\n") + } + + @Test("nl -ba numbers file lines for sed slicing") + func nlBAForFileInput() async throws { + let (session, root) = try await TestSupport.makeSession() + defer { TestSupport.removeDirectory(root) } + + _ = await session.run(#"printf 'import Foundation\n\nlet title = "Bash"\nlet shell = true\nprint(title)\n' > App.swift"#) + + let result = await session.run("nl -ba App.swift | sed -n '3,5p'") + #expect(result.exitCode == 0) + #expect( + result.stdoutString == + " 3\tlet title = \"Bash\"\n 4\tlet shell = true\n 5\tprint(title)\n" + ) + } + + @Test("nl -ba supports stdin input") + func nlBAForStandardInput() async throws { + let (session, root) = try await TestSupport.makeSession() + defer { TestSupport.removeDirectory(root) } + + let result = await session.run("printf 'alpha\\n\\nbeta\\n' | nl -ba") + #expect(result.exitCode == 0) + #expect(result.stdoutString == " 1\talpha\n 2\t\n 3\tbeta\n") + } +}