diff --git a/supacode/Clients/Git/GitClient.swift b/supacode/Clients/Git/GitClient.swift index d71d5112..0ea37f03 100644 --- a/supacode/Clients/Git/GitClient.swift +++ b/supacode/Clients/Git/GitClient.swift @@ -434,10 +434,10 @@ struct GitClient { ) async let untrackedOutput = runGit( operation: .untrackedFilePaths, - arguments: ["-C", path, "-c", "core.quotePath=false", "ls-files", "--others", "--exclude-standard"] + arguments: ["-C", path, "-c", "core.quotePath=false", "ls-files", "--others", "--exclude-standard", "-z"] ) let tracked = parseShortstat(try await diffOutput) - let untrackedPaths = parseFileList(try await untrackedOutput) + let untrackedPaths = parseNULFileList(try await untrackedOutput) let untrackedLines = Self.countLinesInFiles(untrackedPaths, relativeTo: worktreeURL) return (added: tracked.added + untrackedLines, removed: tracked.removed) } catch { @@ -452,21 +452,63 @@ struct GitClient { guard let handle = try? FileHandle(forReadingFrom: indexURL) else { return nil } defer { try? handle.close() } guard let header = try? handle.read(upToCount: 12), header.count == 12 else { return nil } - return header[8...11].withUnsafeBytes { Int($0.load(as: UInt32.self).bigEndian) } + guard header.prefix(4).elementsEqual("DIRC".utf8) else { return nil } + let version = Self.bigEndianUInt32(from: header, offset: 4) + guard (2...4).contains(version) else { return nil } + return Int(Self.bigEndianUInt32(from: header, offset: 8)) } nonisolated static func countLinesInFiles(_ relativePaths: [String], relativeTo base: URL) -> Int { var total = 0 for relativePath in relativePaths { let fileURL = base.appending(path: relativePath) - guard let data = try? Data(contentsOf: fileURL, options: .mappedIfSafe) else { continue } - let prefixCount = min(data.count, 8192) - if data.prefix(prefixCount).contains(0x00) { continue } - total += data.reduce(0) { $0 + ($1 == 0x0A ? 1 : 0) } + total += Self.countLines(in: fileURL) ?? 0 } return total } + nonisolated private static func bigEndianUInt32(from data: Data, offset: Int) -> UInt32 { + data[offset..<(offset + 4)].reduce(UInt32(0)) { ($0 << 8) | UInt32($1) } + } + + nonisolated private static func countLines(in fileURL: URL) -> Int? { + guard let handle = try? FileHandle(forReadingFrom: fileURL) else { return nil } + defer { try? handle.close() } + + let binaryProbeByteCount = 8_192 + let chunkByteCount = 64 * 1_024 + var probedByteCount = 0 + var lineCount = 0 + var isEmpty = true + var lastByte: UInt8? + + while true { + let chunk: Data + do { + guard let readChunk = try handle.read(upToCount: chunkByteCount) else { break } + chunk = readChunk + } catch { + return nil + } + guard !chunk.isEmpty else { break } + + isEmpty = false + if probedByteCount < binaryProbeByteCount { + let remainingProbeCount = binaryProbeByteCount - probedByteCount + let probe = chunk.prefix(remainingProbeCount) + if probe.contains(0x00) { return nil } + probedByteCount += probe.count + } + lineCount += chunk.reduce(0) { $0 + ($1 == 0x0A ? 1 : 0) } + lastByte = chunk.last + } + + if !isEmpty, lastByte != 0x0A { + lineCount += 1 + } + return lineCount + } + nonisolated private static func resolveGitDirectory(for worktreeURL: URL) -> URL? { let gitURL = worktreeURL.appending(path: ".git") var isDirectory = ObjCBool(false) @@ -806,6 +848,12 @@ struct GitClient { .filter { !$0.isEmpty } } + nonisolated private func parseNULFileList(_ output: String) -> [String] { + output + .split(separator: "\0", omittingEmptySubsequences: true) + .map(String.init) + } + nonisolated private func parseFileListCount(_ output: String) -> Int { parseFileList(output).count } diff --git a/supacode/Features/Repositories/Views/WorktreeDetailTitleView.swift b/supacode/Features/Repositories/Views/WorktreeDetailTitleView.swift index 657309b2..8b46ec94 100644 --- a/supacode/Features/Repositories/Views/WorktreeDetailTitleView.swift +++ b/supacode/Features/Repositories/Views/WorktreeDetailTitleView.swift @@ -56,7 +56,8 @@ struct WorktreeDetailTitleView: View { )) } else { // Button wrapper gives folder/workspace the same toolbar padding as the branch button. - Button {} label: { + Button { + } label: { labelContent } } diff --git a/supacodeTests/GitClientLineChangesTests.swift b/supacodeTests/GitClientLineChangesTests.swift index 689be317..fa1a62e8 100644 --- a/supacodeTests/GitClientLineChangesTests.swift +++ b/supacodeTests/GitClientLineChangesTests.swift @@ -41,6 +41,7 @@ struct GitClientLineChangesTests { let untrackedArgs = try #require(calls.first { $0.contains("ls-files") }) #expect(untrackedArgs.contains("--others")) #expect(untrackedArgs.contains("--exclude-standard")) + #expect(untrackedArgs.contains("-z")) } @Test func lineChangesHandlesMissingDeletions() async { @@ -112,7 +113,7 @@ struct GitClientLineChangesTests { stdout: " 1 file changed, 10 insertions(+), 2 deletions(-)\n", stderr: "", exitCode: 0) } if arguments.contains("ls-files") { - return ShellOutput(stdout: "new_file.swift\n", stderr: "", exitCode: 0) + return ShellOutput(stdout: "new_file.swift\0", stderr: "", exitCode: 0) } return ShellOutput(stdout: "", stderr: "", exitCode: 0) }, @@ -150,7 +151,39 @@ struct GitClientLineChangesTests { return ShellOutput(stdout: "", stderr: "", exitCode: 0) } if arguments.contains("ls-files") { - return ShellOutput(stdout: "image.png\nreadme.txt\n", stderr: "", exitCode: 0) + return ShellOutput(stdout: "image.png\0readme.txt\0", stderr: "", exitCode: 0) + } + return ShellOutput(stdout: "", stderr: "", exitCode: 0) + }, + runLoginImpl: { _, _, _, _ in ShellOutput(stdout: "", stderr: "", exitCode: 0) } + ) + let client = GitClient(shell: shell) + + let changes = await client.lineChanges(at: tempRoot) + + #expect(changes?.added == 2) + #expect(changes?.removed == 0) + } + + @Test func lineChangesParsesNULSeparatedUntrackedFilePaths() async throws { + let fileManager = FileManager.default + let tempRoot = fileManager.temporaryDirectory.appending(path: UUID().uuidString) + defer { try? fileManager.removeItem(at: tempRoot) } + let gitDirectory = tempRoot.appending(path: ".git") + try fileManager.createDirectory(at: gitDirectory, withIntermediateDirectories: true) + let headURL = gitDirectory.appending(path: "HEAD") + try "ref: refs/heads/main\n".write(to: headURL, atomically: true, encoding: .utf8) + + let relativePath = " leading space\nname.txt" + try "alpha\nbeta".write(to: tempRoot.appending(path: relativePath), atomically: true, encoding: .utf8) + + let shell = ShellClient( + run: { _, arguments, _ in + if arguments.contains("--shortstat") { + return ShellOutput(stdout: "", stderr: "", exitCode: 0) + } + if arguments.contains("ls-files") { + return ShellOutput(stdout: "\(relativePath)\0", stderr: "", exitCode: 0) } return ShellOutput(stdout: "", stderr: "", exitCode: 0) }, @@ -198,18 +231,32 @@ struct GitClientLineChangesTests { let gitDirectory = tempRoot.appending(path: ".git") try fileManager.createDirectory(at: gitDirectory, withIntermediateDirectories: true) - var header = Data() - header.append(contentsOf: "DIRC".utf8) - var version = UInt32(2).bigEndian - header.append(Data(bytes: &version, count: 4)) - var entryCount = UInt32(42_000).bigEndian - header.append(Data(bytes: &entryCount, count: 4)) - try header.write(to: gitDirectory.appending(path: "index")) + try writeGitIndexHeader(entryCount: 42_000, to: gitDirectory.appending(path: "index")) let count = GitClient.indexEntryCount(at: tempRoot) #expect(count == 42_000) } + @Test func indexEntryCountRejectsInvalidHeader() throws { + let fileManager = FileManager.default + let tempRoot = fileManager.temporaryDirectory.appending(path: UUID().uuidString) + defer { try? fileManager.removeItem(at: tempRoot) } + let gitDirectory = tempRoot.appending(path: ".git") + try fileManager.createDirectory(at: gitDirectory, withIntermediateDirectories: true) + + var invalidMagic = Data() + invalidMagic.append(contentsOf: "NOPE".utf8) + invalidMagic.append(contentsOf: [0, 0, 0, 2]) + invalidMagic.append(contentsOf: [0, 0, 0, 1]) + try invalidMagic.write(to: gitDirectory.appending(path: "index")) + + #expect(GitClient.indexEntryCount(at: tempRoot) == nil) + + try writeGitIndexHeader(version: 99, entryCount: 1, to: gitDirectory.appending(path: "index")) + + #expect(GitClient.indexEntryCount(at: tempRoot) == nil) + } + @Test func indexEntryCountReturnsNilForMissingIndex() { let count = GitClient.indexEntryCount(at: URL(fileURLWithPath: "/nonexistent")) #expect(count == nil) @@ -227,6 +274,18 @@ struct GitClientLineChangesTests { #expect(count == 5) } + @Test func countLinesInFilesCountsFinalLineWithoutTrailingNewline() throws { + let fileManager = FileManager.default + let tempRoot = fileManager.temporaryDirectory.appending(path: UUID().uuidString) + defer { try? fileManager.removeItem(at: tempRoot) } + try fileManager.createDirectory(at: tempRoot, withIntermediateDirectories: true) + try "hello".write(to: tempRoot.appending(path: "single.txt"), atomically: true, encoding: .utf8) + try "a\nb".write(to: tempRoot.appending(path: "multi.txt"), atomically: true, encoding: .utf8) + + let count = GitClient.countLinesInFiles(["single.txt", "multi.txt"], relativeTo: tempRoot) + #expect(count == 3) + } + @Test func countLinesInFilesSkipsBinaryFiles() throws { let fileManager = FileManager.default let tempRoot = fileManager.temporaryDirectory.appending(path: UUID().uuidString) @@ -252,4 +311,22 @@ struct GitClientLineChangesTests { let count = GitClient.countLinesInFiles(["exists.txt", "gone.txt"], relativeTo: tempRoot) #expect(count == 2) } + + private func writeGitIndexHeader( + version: UInt32 = 2, + entryCount: UInt32, + to url: URL + ) throws { + var header = Data() + header.append(contentsOf: "DIRC".utf8) + header.append(UInt8((version >> 24) & 0xff)) + header.append(UInt8((version >> 16) & 0xff)) + header.append(UInt8((version >> 8) & 0xff)) + header.append(UInt8(version & 0xff)) + header.append(UInt8((entryCount >> 24) & 0xff)) + header.append(UInt8((entryCount >> 16) & 0xff)) + header.append(UInt8((entryCount >> 8) & 0xff)) + header.append(UInt8(entryCount & 0xff)) + try header.write(to: url) + } }