Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Tables from every schema in Open Quickly and the sidebar filter, and `schema.table` searches in both. (#3048)
- Recent-tab switching on Control-Tab, with a list of the window's tabs while Control is held. (#2524)
- **Extensions** for SQLite and local libSQL connections, loading sqlite-vec, SpatiaLite and other libraries on connect. (#2502)
- Version history for saved queries, with **Restore This Version**. (#2505)
- Git status letters, history and **Discard Changes…** for files in a linked SQL folder. (#2505)

### Changed

Expand Down
2 changes: 2 additions & 0 deletions TablePro/Core/Events/AppEvents.swift
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,8 @@ final class AppEvents {
/// uniformly handle "this update may affect me" via `payload == nil || payload == self.connectionId`.
let linkedSQLFoldersDidUpdate = PassthroughSubject<UUID?, Never>()

let versionHistoryRefreshRequested = PassthroughSubject<UUID, Never>()

// MARK: - License & Sync

let licenseStatusDidChange = PassthroughSubject<Void, Never>()
Expand Down
73 changes: 73 additions & 0 deletions TablePro/Core/Git/GitClient.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
//
// GitClient.swift
// TablePro
//

import Foundation
import os

internal struct GitCommandFailure: LocalizedError, Equatable {
let exitCode: Int32
let message: String

var errorDescription: String? {
message.isEmpty ? String(format: String(localized: "Git exited with status %d."), exitCode) : message
}
}

internal struct GitClient: Sendable {
private static let logger = Logger(subsystem: "com.TablePro", category: "GitClient")

let runner: GitProcessRunner

static func make(locator: GitExecutableLocator = .system) -> GitClient? {
locator.locate().map { GitClient(runner: GitProcessRunner(executableURL: $0)) }
}

func repositoryInfo(in directory: URL) async throws -> GitRepositoryInfo? {
let result = try await runner.run(.repositoryInfo(in: directory))
guard result.succeeded else {
Self.logger.debug("Not a work tree: \(result.errorMessage, privacy: .private)")
return nil
}
return GitRepositoryInfoParser.parse(result.standardOutput)
}

func hasCommits(in directory: URL) async throws -> Bool {
try await headCommit(in: directory) != nil
}

func headCommit(in directory: URL) async throws -> String? {
let result = try await runner.run(.verifyHead(in: directory))
guard result.succeeded else { return nil }
return (String(bytes: result.standardOutput, encoding: .utf8) ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
}

func status(in directory: URL, pathspec: String = ".") async throws -> [GitStatusRecord] {
let result = try await runner.run(.status(in: directory, pathspec: pathspec))
guard result.succeeded else { throw failure(result) }
return GitStatusParser.parse(result.standardOutput)
}

func trackedFiles(in directory: URL) async throws -> [String] {
let result = try await runner.run(.trackedFiles(in: directory))
guard result.succeeded else { throw failure(result) }
return GitOutputTokens.split(result.standardOutput, separator: 0).filter { !$0.isEmpty }
}

func history(of fileURL: URL, limit: Int) async throws -> [GitCommitRecord] {
let result = try await runner.run(.fileHistory(of: fileURL, limit: limit))
guard result.succeeded else { throw failure(result) }
return GitLogParser.parse(result.standardOutput)
}

func blob(revision: String, path: String, in directory: URL) async throws -> Data {
let result = try await runner.run(.blob(revision: revision, path: path, in: directory))
guard result.succeeded else { throw failure(result) }
return result.standardOutput
}

private func failure(_ result: GitProcessResult) -> GitCommandFailure {
GitCommandFailure(exitCode: result.exitCode, message: result.errorMessage)
}
}
91 changes: 91 additions & 0 deletions TablePro/Core/Git/GitCommand.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
//
// GitCommand.swift
// TablePro
//

import Foundation

internal struct GitCommand: Equatable, Sendable {
let arguments: [String]
let workingDirectory: URL

static let hardeningArguments: [String] = [
"--no-optional-locks",
"--no-pager",
"--literal-pathspecs",
"-c", "core.fsmonitor=false",
"-c", "core.hooksPath=/dev/null",
"-c", "color.ui=never",
"-c", "log.showSignature=false",
"-c", "safe.bareRepository=explicit",
]

static let environmentOverrides: [String: String] = [
"GIT_OPTIONAL_LOCKS": "0",
"GIT_TERMINAL_PROMPT": "0",
"GIT_PAGER": "cat",
"GIT_NO_LAZY_FETCH": "1",
]

static let inheritedRepositoryVariables: Set<String> = [
"GIT_DIR",
"GIT_WORK_TREE",
"GIT_INDEX_FILE",
"GIT_OBJECT_DIRECTORY",
"GIT_ALTERNATE_OBJECT_DIRECTORIES",
"GIT_COMMON_DIR",
"GIT_NAMESPACE",
"GIT_CEILING_DIRECTORIES",
"GIT_CONFIG_PARAMETERS",
"GIT_CONFIG_COUNT",
"GIT_EXTERNAL_DIFF",
]

static let historyFormat = "%x1e%H%x1f%an%x1f%aI%x1f%s"

var processArguments: [String] {
Self.hardeningArguments + arguments
}

static func environment(base: [String: String]) -> [String: String] {
var environment = base.filter { !inheritedRepositoryVariables.contains($0.key) }
environment.merge(environmentOverrides) { _, override in override }
return environment
}

static func repositoryInfo(in directory: URL) -> GitCommand {
GitCommand(
arguments: ["rev-parse", "--show-toplevel", "--absolute-git-dir", "--show-prefix"],
workingDirectory: directory
)
}

static func verifyHead(in directory: URL) -> GitCommand {
GitCommand(arguments: ["rev-parse", "--verify", "--quiet", "HEAD"], workingDirectory: directory)
}

static func status(in directory: URL, pathspec: String = ".") -> GitCommand {
GitCommand(
arguments: ["status", "--porcelain=v2", "-z", "--untracked-files=all", "--find-renames", "--", pathspec],
workingDirectory: directory
)
}

static func trackedFiles(in directory: URL) -> GitCommand {
GitCommand(arguments: ["ls-files", "-z", "--full-name", "--", "."], workingDirectory: directory)
}

static func fileHistory(of fileURL: URL, limit: Int) -> GitCommand {
GitCommand(
arguments: [
"log", "--follow", "-z", "--no-color", "--no-show-signature", "--name-status",
"--max-count=\(limit)", "--format=\(historyFormat)", "--", fileURL.lastPathComponent,
],
workingDirectory: fileURL.deletingLastPathComponent()
)
}

static func blob(revision: String, path: String, in directory: URL) -> GitCommand {
GitCommand(arguments: ["cat-file", "blob", "--end-of-options", "\(revision):\(path)"], workingDirectory: directory)
}
}
42 changes: 42 additions & 0 deletions TablePro/Core/Git/GitExecutableLocator.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
//
// GitExecutableLocator.swift
// TablePro
//

import Foundation

internal struct GitExecutableLocator: Sendable {
static let developerDirectoryLink = "/var/db/xcode_select_link"
static let homebrewCandidates = ["/opt/homebrew/bin/git", "/usr/local/bin/git"]
static let commandLineToolsGit = "/Library/Developer/CommandLineTools/usr/bin/git"
static let defaultXcodeGit = "/Applications/Xcode.app/Contents/Developer/usr/bin/git"
static let installerShim = "/usr/bin/git"

let isExecutable: @Sendable (String) -> Bool
let developerDirectory: @Sendable () -> String?

static let system = GitExecutableLocator(
isExecutable: { FileManager.default.isExecutableFile(atPath: $0) },
developerDirectory: { try? FileManager.default.destinationOfSymbolicLink(atPath: developerDirectoryLink) }
)

func candidates() -> [String] {
var paths = Self.homebrewCandidates
if let developerDirectory = developerDirectory(), developerDirectory.hasPrefix("/") {
paths.append((developerDirectory as NSString).appendingPathComponent("usr/bin/git"))
}
paths.append(Self.commandLineToolsGit)
paths.append(Self.defaultXcodeGit)

var seen: Set<String> = []
return paths.filter { path in
let standardized = (path as NSString).standardizingPath
guard standardized != Self.installerShim else { return false }
return seen.insert(standardized).inserted
}
}

func locate() -> URL? {
candidates().first(where: isExecutable).map { URL(fileURLWithPath: $0) }
}
}
126 changes: 126 additions & 0 deletions TablePro/Core/Git/GitFileStatus.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
//
// GitFileStatus.swift
// TablePro
//

import Foundation

internal struct GitFileStatus: Hashable, Sendable {
internal enum Change: Hashable, Sendable {
case unmodified
case modified
case added
case deleted
case renamed
case copied
case typeChanged
case unmerged

init(code: Character) {
switch code {
case "M": self = .modified
case "A": self = .added
case "D": self = .deleted
case "R": self = .renamed
case "C": self = .copied
case "T": self = .typeChanged
case "U": self = .unmerged
default: self = .unmodified
}
}
}

internal enum Badge: Hashable, Sendable {
case modified
case added
case renamed
case untracked
case conflicted

var letter: String {
switch self {
case .modified: return "M"
case .added: return "A"
case .renamed: return "R"
case .untracked: return "U"
case .conflicted: return "!"
}
}

var label: String {
switch self {
case .modified: return String(localized: "Modified")
case .added: return String(localized: "Added")
case .renamed: return String(localized: "Renamed")
case .untracked: return String(localized: "Untracked")
case .conflicted: return String(localized: "Conflicted")
}
}
}

let staged: Change
let unstaged: Change
let isUntracked: Bool
let isConflicted: Bool

static let untracked = GitFileStatus(staged: .unmodified, unstaged: .unmodified, isUntracked: true)

init(staged: Change, unstaged: Change, isUntracked: Bool = false, isUnmergedEntry: Bool = false) {
self.staged = staged
self.unstaged = unstaged
self.isUntracked = isUntracked
self.isConflicted = isUnmergedEntry || staged == .unmerged || unstaged == .unmerged
}

init(code: Substring, isUnmergedEntry: Bool = false) {
let characters = Array(code)
self.init(
staged: Change(code: characters.first ?? "."),
unstaged: Change(code: characters.count > 1 ? characters[1] : "."),
isUnmergedEntry: isUnmergedEntry
)
}

var hasStagedChanges: Bool {
!isUntracked && staged != .unmodified
}

var hasUnstagedChanges: Bool {
isUntracked || unstaged != .unmodified
}

var badge: Badge {
if isConflicted { return .conflicted }
if isUntracked { return .untracked }
switch staged {
case .added: return .added
case .renamed, .copied: return .renamed
default: return .modified
}
}

var canDiscardChanges: Bool {
guard !isUntracked, !isConflicted else { return false }
return unstaged == .modified || unstaged == .typeChanged
}

var hasCommittedHistory: Bool {
guard !isUntracked else { return false }
switch staged {
case .added, .renamed, .copied: return false
default: return true
}
}

var accessibilityDescription: String {
guard !isUntracked, !isConflicted else { return badge.label }
switch (hasStagedChanges, unstaged != .unmodified) {
case (true, false):
return String(format: String(localized: "%@, staged"), badge.label)
case (true, true):
return String(format: String(localized: "%@, partly staged"), badge.label)
default:
return badge.label
}
}
}
Loading
Loading