diff --git a/.github/workflows/auto-merge-release.yml b/.github/workflows/auto-merge-release.yml index ba2a4e2..8dfc8f2 100644 --- a/.github/workflows/auto-merge-release.yml +++ b/.github/workflows/auto-merge-release.yml @@ -1,9 +1,13 @@ name: Auto-merge release-please PR on: - pull_request: + # Triggers after the Release workflow completes on main. + # workflow_run always runs in the default-branch context, so it is never + # blocked by the "first-time contributor" approval gate. + workflow_run: + workflows: ["Release"] + types: [completed] branches: [main] - types: [opened, synchronize, reopened] permissions: contents: write @@ -11,21 +15,48 @@ permissions: jobs: auto-merge: - # Only run on release-please PRs - if: github.actor == 'github-actions[bot]' && startsWith(github.head_ref, 'release-please--') runs-on: macos-26 + # Only proceed when the Release workflow succeeded + if: github.event.workflow_run.conclusion == 'success' steps: + - name: Find open release-please PR + id: find-pr + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + PR_NUMBER=$(gh pr list \ + --repo "${{ github.repository }}" \ + --head "release-please--branches--main" \ + --state open \ + --json number \ + --jq '.[0].number // empty') + + if [[ -z "${PR_NUMBER}" ]]; then + echo "No open release-please PR found; nothing to do." + echo "found=false" >> "$GITHUB_OUTPUT" + else + echo "Found release-please PR #${PR_NUMBER}" + echo "found=true" >> "$GITHUB_OUTPUT" + echo "number=${PR_NUMBER}" >> "$GITHUB_OUTPUT" + fi + - uses: actions/checkout@v4 + if: steps.find-pr.outputs.found == 'true' + with: + ref: release-please--branches--main - name: Build + if: steps.find-pr.outputs.found == 'true' run: swift build - name: Run tests + if: steps.find-pr.outputs.found == 'true' run: swift run GitKitChecks - name: Approve and auto-merge + if: steps.find-pr.outputs.found == 'true' env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | - gh pr review "${{ github.event.pull_request.number }}" --approve - gh pr merge "${{ github.event.pull_request.number }}" --squash --auto + gh pr review "${{ steps.find-pr.outputs.number }}" --approve + gh pr merge "${{ steps.find-pr.outputs.number }}" --squash --auto diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 979ba5c..01d978c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -22,6 +22,7 @@ jobs: id: release with: release-type: simple + token: ${{ secrets.RELEASE_TOKEN }} build: needs: release-please diff --git a/Sources/GitKit/CLIGitService.swift b/Sources/GitKit/CLIGitService.swift index 841bc95..7b63978 100644 --- a/Sources/GitKit/CLIGitService.swift +++ b/Sources/GitKit/CLIGitService.swift @@ -9,10 +9,10 @@ private let recordSeparator = "\u{1e}" // between records /// `GitService` backed by the user's installed `git` binary. public struct CLIGitService: GitService { public let root: URL - private let runner: GitRunner + private let runner: any GitRunnerProtocol - /// Creates a service rooted at `directory`. Resolves to the repository top-level so - /// that all paths are reported relative to a stable root. + /// Creates a service rooted at a local `directory`. Resolves to the repository + /// top-level so that all paths are reported relative to a stable root. public init(directory: URL, executablePath: String? = nil) async throws { let probe = GitRunner(workingDirectory: directory, executablePath: executablePath) let result = try await probe.runRaw(["rev-parse", "--show-toplevel"]) @@ -24,6 +24,17 @@ public struct CLIGitService: GitService { self.runner = GitRunner(workingDirectory: self.root, executablePath: executablePath) } + /// Creates a service for a remote repository accessed over SSH. + public init(sshRunner: SSHGitRunner) async throws { + let result = try await sshRunner.runRaw(["rev-parse", "--show-toplevel"]) + guard result.succeeded else { + throw GitError.notARepository(sshRunner.remotePath) + } + let top = result.stdoutString.trimmingCharacters(in: .whitespacesAndNewlines) + self.root = URL(fileURLWithPath: top) + self.runner = sshRunner + } + /// Discovers the repository top-level for an arbitrary path, returning nil if none. public static func repositoryRoot(for directory: URL, executablePath: String? = nil) async -> URL? { let probe = GitRunner(workingDirectory: directory, executablePath: executablePath) diff --git a/Sources/GitKit/GitRunner.swift b/Sources/GitKit/GitRunner.swift index 276b597..0079486 100644 --- a/Sources/GitKit/GitRunner.swift +++ b/Sources/GitKit/GitRunner.swift @@ -32,7 +32,7 @@ private final class CaptureBox: @unchecked Sendable { /// /// An actor enforces one mutating invocation at a time per repository, avoiding /// `index.lock` contention while still allowing concurrency across repositories. -public actor GitRunner { +public actor GitRunner: GitRunnerProtocol { /// Working directory for invocations. `nil` for repo-independent commands (e.g. clone, version). public let workingDirectory: URL? private let executableURL: URL diff --git a/Sources/GitKit/GitRunnerProtocol.swift b/Sources/GitKit/GitRunnerProtocol.swift new file mode 100644 index 0000000..426cde8 --- /dev/null +++ b/Sources/GitKit/GitRunnerProtocol.swift @@ -0,0 +1,36 @@ +import Foundation + +/// Abstraction over local and SSH-based git command execution. +/// +/// Both `GitRunner` (local) and `SSHGitRunner` (remote) conform to this protocol, +/// allowing `CLIGitService` to execute git commands transparently against either. +public protocol GitRunnerProtocol: Actor { + /// Working directory for the repository. For local runners this is a filesystem URL; + /// for SSH runners it\u{2019}s a synthetic URL representing the remote path. + var workingDirectory: URL? { get } + + /// Runs `git ` and returns the result without throwing on non-zero exit. + func runRaw(_ arguments: [String]) async throws -> GitProcessResult + + /// Runs `git `, throwing on non-zero exit. Returns stdout as `Data`. + @discardableResult + func run(_ arguments: [String]) async throws -> Data + + /// Runs `git `, throwing on non-zero exit. Returns stdout as a string. + func runString(_ arguments: [String]) async throws -> String + + /// Runs `git `, streaming stderr progress lines to `onProgress`. + /// Throws on non-zero exit. + @discardableResult + func runStreaming( + _ arguments: [String], + onProgress: (@Sendable (String) -> Void)? + ) async throws -> GitProcessResult +} + +public extension GitRunnerProtocol { + @discardableResult + func runStreaming(_ arguments: [String]) async throws -> GitProcessResult { + try await runStreaming(arguments, onProgress: nil) + } +} diff --git a/Sources/GitKit/Models/FileStatus.swift b/Sources/GitKit/Models/FileStatus.swift index 25a0ebf..ad22742 100644 --- a/Sources/GitKit/Models/FileStatus.swift +++ b/Sources/GitKit/Models/FileStatus.swift @@ -57,8 +57,14 @@ public struct WorkingTreeStatus: Sendable { public let isDetached: Bool public let files: [FileStatus] - public var stagedFiles: [FileStatus] { files.filter(\.isStaged) } - public var unstagedFiles: [FileStatus] { files.filter { $0.hasUnstagedChanges || $0.isUntracked } } + /// Conflicted (unmerged) files. Surfaced separately so they don't masquerade as ordinary + /// staged/unstaged edits — an unmerged entry reports both an index and a worktree state. + public var conflictedFiles: [FileStatus] { files.filter(\.isConflicted) } + public var stagedFiles: [FileStatus] { files.filter { $0.isStaged && !$0.isConflicted } } + public var unstagedFiles: [FileStatus] { + files.filter { !$0.isConflicted && ($0.hasUnstagedChanges || $0.isUntracked) } + } + public var hasConflicts: Bool { files.contains(where: \.isConflicted) } public var hasChanges: Bool { !files.isEmpty } public init( diff --git a/Sources/GitKit/RepositoryWatcher.swift b/Sources/GitKit/RepositoryWatcher.swift index f0fd43f..2aa2229 100644 --- a/Sources/GitKit/RepositoryWatcher.swift +++ b/Sources/GitKit/RepositoryWatcher.swift @@ -1,9 +1,15 @@ import Foundation +/// Common interface for local (FSEvents) and remote (SSH polling) repository watchers. +public protocol RepositoryWatching: AnyObject, Sendable { + func start() + func stop() +} + /// Watches a repository's working tree (and its `.git` directory) with FSEvents, invoking /// `onChange` when anything changes on disk. Events are coalesced by FSEvents' latency /// window; callers should debounce further before doing expensive work. -public final class RepositoryWatcher: @unchecked Sendable { +public final class RepositoryWatcher: RepositoryWatching, @unchecked Sendable { private let path: String private let onChange: @Sendable () -> Void private let queue = DispatchQueue(label: "com.gitify.repository-watcher", qos: .utility) diff --git a/Sources/GitKit/SSHGitRunner.swift b/Sources/GitKit/SSHGitRunner.swift new file mode 100644 index 0000000..923a88a --- /dev/null +++ b/Sources/GitKit/SSHGitRunner.swift @@ -0,0 +1,216 @@ +import Foundation + +/// Mutable storage shared across pipe-reading dispatch closures. Access is synchronized +/// by the surrounding `DispatchGroup`, so `@unchecked Sendable` is sound. +private final class SSHCaptureBox: @unchecked Sendable { + var stdout = Data() + var stderr = Data() + var stderrText = "" +} + +/// Executes git commands on a remote server over SSH. +/// +/// Drop-in replacement for `GitRunner` that wraps every git invocation in +/// `ssh user@host git -C /path `. The user\u{2019}s SSH config, agent, and keys +/// handle authentication transparently. +/// +/// Like `GitRunner`, the actor serialises invocations to prevent `index.lock` contention +/// on the remote repository. +public actor SSHGitRunner: GitRunnerProtocol { + public let workingDirectory: URL? + public let remotePath: String + private let host: String + private let user: String + private let port: Int + private let sshURL: URL + + public init(host: String, user: String = "git", port: Int = 22, remotePath: String) { + self.host = host + self.user = user + self.port = port + self.remotePath = remotePath + // Synthetic URL for identity/display purposes only. + self.workingDirectory = URL(fileURLWithPath: remotePath) + self.sshURL = URL(fileURLWithPath: SSHGitRunner.sshPath) + } + + private static let sshPath: String = { + let candidates = ["/usr/bin/ssh", "/opt/homebrew/bin/ssh", "/usr/local/bin/ssh"] + for path in candidates where FileManager.default.isExecutableFile(atPath: path) { + return path + } + return "/usr/bin/ssh" + }() + + /// Builds the SSH arguments for running a git command remotely. + /// + /// The remote command is constructed as a single shell string so that git format + /// strings containing control characters (\u{1f}, \u{1e}) survive the SSH transport. + /// Environment variables are set via `env` to match the local `GitRunner` behaviour. + private func sshArgs(gitArguments: [String]) -> [String] { + let quotedPath = shellQuote(remotePath) + let quotedGitArgs = gitArguments.map { shellQuote($0) }.joined(separator: " ") + let remoteCommand = "LC_ALL=C GIT_TERMINAL_PROMPT=0 GIT_OPTIONAL_LOCKS=0 GIT_EDITOR=true GIT_ALLOW_PROTOCOL=https:http:ssh:git:file git -C \(quotedPath) \(quotedGitArgs)" + + return [ + "-o", "BatchMode=yes", + "-o", "ConnectTimeout=10", + "-o", "StrictHostKeyChecking=accept-new", + "-p", String(port), + "-l", user, + host, + remoteCommand, + ] + } + + public func runRaw(_ arguments: [String]) async throws -> GitProcessResult { + let sshURL = self.sshURL + let args = sshArgs(gitArguments: arguments) + + return try await withCheckedThrowingContinuation { continuation in + DispatchQueue.global(qos: .userInitiated).async { + let process = Process() + process.executableURL = sshURL + process.arguments = args + + let stdoutPipe = Pipe() + let stderrPipe = Pipe() + process.standardOutput = stdoutPipe + process.standardError = stderrPipe + + let box = SSHCaptureBox() + let group = DispatchGroup() + group.enter() + DispatchQueue.global(qos: .userInitiated).async { + box.stdout = (try? stdoutPipe.fileHandleForReading.readToEnd()) ?? Data() + group.leave() + } + group.enter() + DispatchQueue.global(qos: .userInitiated).async { + box.stderr = (try? stderrPipe.fileHandleForReading.readToEnd()) ?? Data() + group.leave() + } + + do { + try process.run() + } catch { + continuation.resume(throwing: GitError.gitUnavailable( + "SSH connection failed: \(error.localizedDescription)")) + return + } + + process.waitUntilExit() + group.wait() + + continuation.resume(returning: GitProcessResult( + exitCode: process.terminationStatus, + stdout: box.stdout, + stderr: String(decoding: box.stderr, as: UTF8.self) + )) + } + } + } + + @discardableResult + public func run(_ arguments: [String]) async throws -> Data { + let result = try await runRaw(arguments) + guard result.succeeded else { + throw GitError.commandFailed( + command: arguments.joined(separator: " "), + exitCode: result.exitCode, + stderr: result.stderr + ) + } + return result.stdout + } + + public func runString(_ arguments: [String]) async throws -> String { + String(decoding: try await run(arguments), as: UTF8.self) + } + + @discardableResult + public func runStreaming( + _ arguments: [String], + onProgress: (@Sendable (String) -> Void)? = nil + ) async throws -> GitProcessResult { + let sshURL = self.sshURL + let args = sshArgs(gitArguments: arguments) + + let result: GitProcessResult = try await withCheckedThrowingContinuation { continuation in + DispatchQueue.global(qos: .userInitiated).async { + let process = Process() + process.executableURL = sshURL + process.arguments = args + + let stdoutPipe = Pipe() + let stderrPipe = Pipe() + process.standardOutput = stdoutPipe + process.standardError = stderrPipe + + let box = SSHCaptureBox() + let group = DispatchGroup() + + group.enter() + DispatchQueue.global(qos: .userInitiated).async { + box.stdout = (try? stdoutPipe.fileHandleForReading.readToEnd()) ?? Data() + group.leave() + } + group.enter() + DispatchQueue.global(qos: .userInitiated).async { + let handle = stderrPipe.fileHandleForReading + var pending = "" + while case let chunk = handle.availableData, !chunk.isEmpty { + let text = String(decoding: chunk, as: UTF8.self) + box.stderrText += text + pending += text + var line = "" + for character in pending { + if character == "\r" || character == "\n" { + let trimmed = line.trimmingCharacters(in: .whitespaces) + if !trimmed.isEmpty { onProgress?(trimmed) } + line = "" + } else { + line.append(character) + } + } + pending = line + } + group.leave() + } + + do { + try process.run() + } catch { + continuation.resume(throwing: GitError.gitUnavailable( + "SSH connection failed: \(error.localizedDescription)")) + return + } + + process.waitUntilExit() + group.wait() + continuation.resume(returning: GitProcessResult( + exitCode: process.terminationStatus, stdout: box.stdout, stderr: box.stderrText)) + } + } + + guard result.succeeded else { + throw GitError.commandFailed(command: arguments.joined(separator: " "), + exitCode: result.exitCode, stderr: result.stderr) + } + return result + } + + /// Shell-quotes a string for safe inclusion in the remote command. + /// Preserves tilde expansion for paths starting with `~/` or bare `~`. + private func shellQuote(_ string: String) -> String { + if string == "~" { return "~" } + if string.hasPrefix("~/") { + return "~/" + singleQuote(String(string.dropFirst(2))) + } + return singleQuote(string) + } + + private func singleQuote(_ string: String) -> String { + "'" + string.replacingOccurrences(of: "'", with: "'\\''") + "'" + } +} diff --git a/Sources/GitKit/SSHRepositoryWatcher.swift b/Sources/GitKit/SSHRepositoryWatcher.swift new file mode 100644 index 0000000..402467d --- /dev/null +++ b/Sources/GitKit/SSHRepositoryWatcher.swift @@ -0,0 +1,64 @@ +import Foundation + +/// Watches a remote repository by polling git state over SSH. +/// Fires `onChange` when the HEAD commit, working-tree status, or ref tips change. +/// +/// Polls every 3 seconds. This is less responsive than FSEvents (0.5 s) but avoids the +/// overhead of a persistent SSH connection. +public final class SSHRepositoryWatcher: RepositoryWatching, @unchecked Sendable { + private let runner: SSHGitRunner + private let onChange: @Sendable () -> Void + private var pollTask: Task? + /// Fingerprint of the last observed state. + private var lastFingerprint = "" + + public init(host: String, user: String, port: Int, remotePath: String, + onChange: @escaping @Sendable () -> Void) { + self.runner = SSHGitRunner(host: host, user: user, port: port, remotePath: remotePath) + self.onChange = onChange + } + + public func start() { + guard pollTask == nil else { return } + pollTask = Task.detached { [weak self] in + while !Task.isCancelled { + try? await Task.sleep(for: .seconds(3)) + guard !Task.isCancelled else { break } + await self?.poll() + } + } + } + + public func stop() { + pollTask?.cancel() + pollTask = nil + } + + deinit { + pollTask?.cancel() + } + + private func poll() async { + // Build a fingerprint from three sources to catch all types of changes: + // 1. HEAD sha \u{2014} detects commits, checkouts, resets on the current branch + // 2. Status byte count \u{2014} detects working-tree / staging area changes + // 3. Ref tips hash \u{2014} detects pushed/deleted branches, tags, remote updates + let headResult = try? await runner.runRaw(["rev-parse", "HEAD"]) + let statusResult = try? await runner.runRaw(["status", "--porcelain=v2", "-z"]) + let refsResult = try? await runner.runRaw(["for-each-ref", "--format=%(refname) %(objectname)"]) + + let head = headResult?.stdoutString.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + let statusLen = statusResult?.stdout.count ?? 0 + let refsHash = refsResult?.stdout.count ?? 0 // Byte length as cheap change detector + + let fingerprint = "\(head):\(statusLen):\(refsHash)" + + if lastFingerprint.isEmpty { + // First poll \u{2014} seed the fingerprint without firing. + lastFingerprint = fingerprint + } else if fingerprint != lastFingerprint { + lastFingerprint = fingerprint + onChange() + } + } +} diff --git a/Sources/Gitify/Model/AppModel.swift b/Sources/Gitify/Model/AppModel.swift index a0e6fcf..2a19b72 100644 --- a/Sources/Gitify/Model/AppModel.swift +++ b/Sources/Gitify/Model/AppModel.swift @@ -21,12 +21,17 @@ final class AppModel { // Hosting accounts (GitHub/GitLab) authenticated by personal access token. private(set) var accounts: [HostingAccount] + // SSH server connections for browsing and cloning remote repositories. + private(set) var sshServers: [SSHServer] + private let store = RepositoryStore() private let accountStore = AccountStore() + private let sshServerStore = SSHServerStore() init() { repositories = store.load() accounts = accountStore.load() + sshServers = sshServerStore.load() // Restore the repository open in the previous session if it still exists, // otherwise fall back to the first in the list. let savedID = UserDefaults.standard.string(forKey: Self.lastSelectedKey) @@ -101,6 +106,61 @@ final class AppModel { return "https://\(user)@\(body)" } + // MARK: - SSH servers + + func addSSHServer(_ server: SSHServer) { + guard !sshServers.contains(where: { $0.id == server.id }) else { return } + sshServers.append(server) + sshServerStore.save(sshServers) + } + + func removeSSHServer(_ server: SSHServer) { + sshServers.removeAll { $0.id == server.id } + sshServerStore.save(sshServers) + } + + /// Clones a repository discovered on an SSH server. + func clone(_ repo: SSHRepo) async { + guard !isCloning else { return } + guard let parent = Prompt.chooseDirectory(prompt: "Clone Here", + message: "Choose where to clone \(repo.name)") else { return } + cloneProgress = "Starting\u{2026}" + defer { cloneProgress = nil } + let progress: @Sendable (String) -> Void = { [weak self] line in + Task { @MainActor in self?.cloneProgress = line } + } + do { + let dest = try await CLIGitService.clone(url: repo.cloneURL, into: parent, onProgress: progress) + await add(directory: dest) + } catch { + presentError("Clone failed: \(error)") + } + } + + /// Opens a remote repository over SSH without cloning it locally. + func openRemote(_ repo: SSHRepo, server: SSHServer) async { + // Check for duplicates. + if let existing = repositories.first(where: { $0.path == repo.path && $0.sshHost == server.host }) { + selectedRepositoryID = existing.id + return + } + // Validate the remote repo is accessible. + let runner = SSHGitRunner(host: server.host, user: server.user, + port: server.port, remotePath: repo.path) + let result = try? await runner.runRaw(["rev-parse", "--show-toplevel"]) + guard let result, result.succeeded else { + presentError("Could not open remote repository.") + return + } + let ref = RepositoryRef( + path: repo.path, name: repo.name, + sshHost: server.host, sshUser: server.user, sshPort: server.port + ) + repositories.append(ref) + store.save(repositories) + selectedRepositoryID = ref.id + } + var selectedRepository: RepositoryRef? { repositories.first { $0.id == selectedRepositoryID } } diff --git a/Sources/Gitify/Model/RepositoryRef.swift b/Sources/Gitify/Model/RepositoryRef.swift index 98395d5..d8c8780 100644 --- a/Sources/Gitify/Model/RepositoryRef.swift +++ b/Sources/Gitify/Model/RepositoryRef.swift @@ -3,15 +3,27 @@ import Foundation /// A persisted reference to a repository the user has added to Gitify. struct RepositoryRef: Identifiable, Codable, Hashable { let id: UUID - /// Absolute path to the working-tree root. + /// Absolute path to the working-tree root (local or remote). var path: String /// Display name (defaults to the directory name). var name: String - init(id: UUID = UUID(), path: String, name: String? = nil) { + // SSH connection details \u{2014} nil for local repos. + var sshHost: String? + var sshUser: String? + var sshPort: Int? + + /// Whether this repository is accessed over SSH rather than the local filesystem. + var isRemote: Bool { sshHost != nil } + + init(id: UUID = UUID(), path: String, name: String? = nil, + sshHost: String? = nil, sshUser: String? = nil, sshPort: Int? = nil) { self.id = id self.path = path self.name = name ?? URL(fileURLWithPath: path).lastPathComponent + self.sshHost = sshHost + self.sshUser = sshUser + self.sshPort = sshPort } var url: URL { URL(fileURLWithPath: path) } diff --git a/Sources/Gitify/Model/RepositoryViewModel.swift b/Sources/Gitify/Model/RepositoryViewModel.swift index 1b8edfc..6c9da93 100644 --- a/Sources/Gitify/Model/RepositoryViewModel.swift +++ b/Sources/Gitify/Model/RepositoryViewModel.swift @@ -53,7 +53,7 @@ final class RepositoryViewModel { private var service: CLIGitService? private var nextSkip: Int? = 0 - private var watcher: RepositoryWatcher? + private var watcher: (any RepositoryWatching)? private var autoRefreshTask: Task? init(ref: RepositoryRef) { @@ -291,7 +291,14 @@ final class RepositoryViewModel { private func resolveService() async throws -> CLIGitService { if let service { return service } - let service = try await CLIGitService(directory: ref.url) + let service: CLIGitService + if let host = ref.sshHost { + let runner = SSHGitRunner(host: host, user: ref.sshUser ?? "git", + port: ref.sshPort ?? 22, remotePath: ref.path) + service = try await CLIGitService(sshRunner: runner) + } else { + service = try await CLIGitService(directory: ref.url) + } self.service = service startWatching(root: service.root) return service @@ -303,11 +310,22 @@ final class RepositoryViewModel { /// commands) refresh the UI automatically. private func startWatching(root: URL) { guard watcher == nil else { return } - let watcher = RepositoryWatcher(root: root) { [weak self] in + let onChange: @Sendable () -> Void = { [weak self] in Task { @MainActor in self?.scheduleAutoRefresh() } } - watcher.start() - self.watcher = watcher + if let host = ref.sshHost { + let sshWatcher = SSHRepositoryWatcher( + host: host, user: ref.sshUser ?? "git", + port: ref.sshPort ?? 22, remotePath: ref.path, + onChange: onChange + ) + sshWatcher.start() + self.watcher = sshWatcher + } else { + let fsWatcher = RepositoryWatcher(root: root, onChange: onChange) + fsWatcher.start() + self.watcher = fsWatcher + } } /// Debounces filesystem events into a single reload, skipping while a load or network diff --git a/Sources/Gitify/Model/SSHRepo.swift b/Sources/Gitify/Model/SSHRepo.swift new file mode 100644 index 0000000..f988c49 --- /dev/null +++ b/Sources/Gitify/Model/SSHRepo.swift @@ -0,0 +1,12 @@ +import Foundation + +/// A repository discovered on a remote SSH server. +struct SSHRepo: Identifiable, Hashable { + var id: String { path } + /// Absolute path on the server (e.g. \u{201c}/srv/git/project.git\u{201d}). + let path: String + /// Display name derived from the path (directory name without .git suffix). + let name: String + /// Full SSH clone URL (e.g. \u{201c}ssh://git@host:22/srv/git/project.git\u{201d}). + let cloneURL: String +} diff --git a/Sources/Gitify/Model/SSHScanner.swift b/Sources/Gitify/Model/SSHScanner.swift new file mode 100644 index 0000000..fd47bda --- /dev/null +++ b/Sources/Gitify/Model/SSHScanner.swift @@ -0,0 +1,264 @@ +import Foundation + +/// Runs SSH commands to test connectivity and discover repositories on a remote server. +/// For local servers (localhost/127.0.0.1), commands run directly without SSH. +/// +/// Uses the user\u{2019}s installed `ssh` binary and their `~/.ssh/config`, agent, and keys. +/// All commands run with `BatchMode=yes` (no interactive prompts) and `ConnectTimeout=5` +/// to avoid hanging when keys are missing or the host is unreachable. +enum SSHScanner { + + /// Tests whether an SSH connection can be established. + /// Returns `nil` on success, or an error description on failure. + static func testConnection(_ server: SSHServer) async -> String? { + if isLocal(server) { return nil } + do { + let result = try await runSSH(server: server, remoteCommand: "echo ok") + if result.exitCode == 0 { return nil } + let stderr = result.stderr.trimmingCharacters(in: .whitespacesAndNewlines) + return stderr.isEmpty ? "Connection failed (exit code \(result.exitCode))." : stderr + } catch { + return error.localizedDescription + } + } + + /// Discovers git repositories under the server\u{2019}s configured base path. + /// + /// Finds non-bare repos (directories containing a `.git` subdirectory) and bare repos + /// (directories ending in `.git` that contain a `HEAD` file). Returns the list sorted + /// by path. + static func discoverRepositories(_ server: SSHServer) async throws -> [SSHRepo] { + let basePath = server.basePath.isEmpty ? "~" : server.basePath + // Two-pronged search: + // 1. Non-bare repos: find directories named ".git" (working-tree repos). + // 2. Bare repos: find directories whose name ends in ".git" (but isn\u{2019}t exactly ".git") + // that contain a HEAD file. + let command = "{ find \(shellQuote(basePath)) -maxdepth 3 -type d -name .git 2>/dev/null; find \(shellQuote(basePath)) -maxdepth 3 -type d -name '*.git' ! -name .git 2>/dev/null; } | head -200" + + let result: ProcessResult + if isLocal(server) { + result = try await runLocal(command: command) + } else { + result = try await runSSH(server: server, remoteCommand: command) + } + + if result.exitCode != 0 && result.exitCode != 1 { + let stderr = result.stderr.trimmingCharacters(in: .whitespacesAndNewlines) + throw SSHScannerError.scanFailed(stderr.isEmpty ? "Scan failed (exit code \(result.exitCode))." : stderr) + } + + let stdout = result.stdout.trimmingCharacters(in: .whitespacesAndNewlines) + guard !stdout.isEmpty else { return [] } + + var repos: [SSHRepo] = [] + for line in stdout.components(separatedBy: "\n") { + let path = line.trimmingCharacters(in: .whitespacesAndNewlines) + guard !path.isEmpty else { continue } + + // A ".git" directory means the parent is a non-bare working tree. + // A "something.git" directory is a bare repo. + let effectivePath: String + if (path as NSString).lastPathComponent == ".git" { + effectivePath = (path as NSString).deletingLastPathComponent + } else { + effectivePath = path + } + + let name = displayName(for: effectivePath) + let cloneURL = buildCloneURL(server: server, remotePath: effectivePath) + repos.append(SSHRepo(path: effectivePath, name: name, cloneURL: cloneURL)) + } + + // Deduplicate and sort. + var seen = Set() + repos = repos.filter { seen.insert($0.path).inserted } + repos.sort { $0.path.localizedCaseInsensitiveCompare($1.path) == .orderedAscending } + return repos + } + + // MARK: - Private + + private struct ProcessResult { + let exitCode: Int32 + let stdout: String + let stderr: String + } + + /// Mutable storage shared across pipe-reading dispatch closures. Access is synchronized + /// by the surrounding `DispatchGroup`, so `@unchecked Sendable` is sound. + private final class CaptureBox: @unchecked Sendable { + var stdout = Data() + var stderr = Data() + } + + /// Whether the server points to the local machine. + private static func isLocal(_ server: SSHServer) -> Bool { + let host = server.host.lowercased() + return host == "localhost" || host == "127.0.0.1" || host == "::1" + } + + /// Runs a shell command locally via `/bin/sh -c`. + private static func runLocal(command: String) async throws -> ProcessResult { + try await withCheckedThrowingContinuation { continuation in + DispatchQueue.global(qos: .userInitiated).async { + let process = Process() + process.executableURL = URL(fileURLWithPath: "/bin/sh") + process.arguments = ["-c", command] + + let stdoutPipe = Pipe() + let stderrPipe = Pipe() + process.standardOutput = stdoutPipe + process.standardError = stderrPipe + + let box = CaptureBox() + let group = DispatchGroup() + group.enter() + DispatchQueue.global(qos: .userInitiated).async { + box.stdout = (try? stdoutPipe.fileHandleForReading.readToEnd()) ?? Data() + group.leave() + } + group.enter() + DispatchQueue.global(qos: .userInitiated).async { + box.stderr = (try? stderrPipe.fileHandleForReading.readToEnd()) ?? Data() + group.leave() + } + + do { + try process.run() + } catch { + continuation.resume(throwing: SSHScannerError.sshUnavailable(error.localizedDescription)) + return + } + + process.waitUntilExit() + group.wait() + + continuation.resume(returning: ProcessResult( + exitCode: process.terminationStatus, + stdout: String(decoding: box.stdout, as: UTF8.self), + stderr: String(decoding: box.stderr, as: UTF8.self) + )) + } + } + } + + /// Runs an SSH command against the given server. + private static func runSSH(server: SSHServer, remoteCommand: String) async throws -> ProcessResult { + let sshPath = sshExecutablePath() + // Validate arguments against injection. + for value in [server.host, server.user] { + guard !value.hasPrefix("-") else { + throw SSHScannerError.invalidArgument("\(value) may not begin with \u{201c}-\u{201d}.") + } + } + guard server.port > 0, server.port <= 65535 else { + throw SSHScannerError.invalidArgument("Port must be between 1 and 65535.") + } + + let args = [ + "-o", "BatchMode=yes", + "-o", "ConnectTimeout=5", + "-o", "StrictHostKeyChecking=accept-new", + "-p", String(server.port), + "-l", server.user, + server.host, + remoteCommand, + ] + + return try await withCheckedThrowingContinuation { continuation in + DispatchQueue.global(qos: .userInitiated).async { + let process = Process() + process.executableURL = URL(fileURLWithPath: sshPath) + process.arguments = args + + let stdoutPipe = Pipe() + let stderrPipe = Pipe() + process.standardOutput = stdoutPipe + process.standardError = stderrPipe + + let box = CaptureBox() + let group = DispatchGroup() + group.enter() + DispatchQueue.global(qos: .userInitiated).async { + box.stdout = (try? stdoutPipe.fileHandleForReading.readToEnd()) ?? Data() + group.leave() + } + group.enter() + DispatchQueue.global(qos: .userInitiated).async { + box.stderr = (try? stderrPipe.fileHandleForReading.readToEnd()) ?? Data() + group.leave() + } + + do { + try process.run() + } catch { + continuation.resume(throwing: SSHScannerError.sshUnavailable(error.localizedDescription)) + return + } + + process.waitUntilExit() + group.wait() + + continuation.resume(returning: ProcessResult( + exitCode: process.terminationStatus, + stdout: String(decoding: box.stdout, as: UTF8.self), + stderr: String(decoding: box.stderr, as: UTF8.self) + )) + } + } + } + + /// Best-effort discovery of the `ssh` binary. + private static func sshExecutablePath() -> String { + let candidates = ["/usr/bin/ssh", "/opt/homebrew/bin/ssh", "/usr/local/bin/ssh"] + for path in candidates where FileManager.default.isExecutableFile(atPath: path) { + return path + } + return "/usr/bin/ssh" + } + + /// Derives a display name from a remote path (e.g. \u{201c}/srv/git/project.git\u{201d} \u{2192} \u{201c}project\u{201d}). + private static func displayName(for path: String) -> String { + var name = (path as NSString).lastPathComponent + if name.hasSuffix(".git") { name = String(name.dropLast(4)) } + return name.isEmpty ? path : name + } + + /// Builds a clone URL for the given server and remote path. + /// Local servers return a file path; remote servers return an SSH URL. + private static func buildCloneURL(server: SSHServer, remotePath: String) -> String { + if isLocal(server) { return remotePath } + if server.port == 22 { + return "\(server.user)@\(server.host):\(remotePath)" + } + return "ssh://\(server.user)@\(server.host):\(server.port)\(remotePath)" + } + + /// Shell-quotes a string for safe inclusion in a shell command. + /// Preserves tilde expansion for paths starting with `~/` or bare `~`. + private static func shellQuote(_ string: String) -> String { + if string == "~" { return "~" } + if string.hasPrefix("~/") { + return "~/" + singleQuote(String(string.dropFirst(2))) + } + return singleQuote(string) + } + + private static func singleQuote(_ string: String) -> String { + "'" + string.replacingOccurrences(of: "'", with: "'\\''") + "'" + } +} + +enum SSHScannerError: LocalizedError { + case sshUnavailable(String) + case scanFailed(String) + case invalidArgument(String) + + var errorDescription: String? { + switch self { + case .sshUnavailable(let detail): "SSH is not available: \(detail)" + case .scanFailed(let detail): detail + case .invalidArgument(let detail): detail + } + } +} diff --git a/Sources/Gitify/Model/SSHServer.swift b/Sources/Gitify/Model/SSHServer.swift new file mode 100644 index 0000000..e1f8351 --- /dev/null +++ b/Sources/Gitify/Model/SSHServer.swift @@ -0,0 +1,51 @@ +import Foundation + +/// A saved SSH server connection used to browse and clone remote repositories. +struct SSHServer: Identifiable, Codable, Hashable { + let id: UUID + /// User-chosen display name (e.g. \u{201c}My VPS\u{201d}). + var label: String + /// Hostname or IP address. + var host: String + /// SSH username (typically \u{201c}git\u{201d} for hosting services). + var user: String + /// SSH port (default 22). + var port: Int + /// Base path on the server to scan for repositories. + var basePath: String + + init(id: UUID = UUID(), label: String, host: String, user: String = "git", + port: Int = 22, basePath: String = "~") { + self.id = id + self.label = label + self.host = host + self.user = user + self.port = port + self.basePath = basePath + } +} + +/// Persists the list of SSH servers to Application Support as JSON. +struct SSHServerStore { + private let fileURL: URL + + init() { + let base = FileManager.default + .urls(for: .applicationSupportDirectory, in: .userDomainMask)[0] + .appendingPathComponent("Gitify", isDirectory: true) + try? FileManager.default.createDirectory(at: base, withIntermediateDirectories: true) + self.fileURL = base.appendingPathComponent("ssh-servers.json") + } + + func load() -> [SSHServer] { + guard let data = try? Data(contentsOf: fileURL) else { return [] } + return (try? JSONDecoder().decode([SSHServer].self, from: data)) ?? [] + } + + func save(_ servers: [SSHServer]) { + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + guard let data = try? encoder.encode(servers) else { return } + try? data.write(to: fileURL, options: .atomic) + } +} diff --git a/Sources/Gitify/Views/AddSSHServerSheet.swift b/Sources/Gitify/Views/AddSSHServerSheet.swift new file mode 100644 index 0000000..1fd5b9c --- /dev/null +++ b/Sources/Gitify/Views/AddSSHServerSheet.swift @@ -0,0 +1,122 @@ +import SwiftUI + +/// Sheet to add an SSH server connection by entering host, user, port, and base path. +/// Layout follows the same hand-laid-out pattern as `AddAccountSheet`. +struct AddSSHServerSheet: View { + let model: AppModel + @Environment(\.dismiss) private var dismiss + @State private var label = "" + @State private var host = "" + @State private var user = "git" + @State private var portString = "22" + @State private var basePath = "~" + @State private var testing = false + @State private var testResult: String? + @State private var testPassed = false + + private var port: Int { Int(portString) ?? 22 } + private var isValid: Bool { + !host.trimmingCharacters(in: .whitespaces).isEmpty && + !user.trimmingCharacters(in: .whitespaces).isEmpty && + port > 0 && port <= 65535 + } + + private let labelWidth: CGFloat = 70 + + var body: some View { + VStack(alignment: .leading, spacing: 16) { + Text("Add SSH Server").font(.title2.bold()) + + Grid(alignment: .leading, horizontalSpacing: 8, verticalSpacing: 10) { + GridRow { + Text("Label").frame(width: labelWidth, alignment: .trailing) + TextField("My Server", text: $label) + .textFieldStyle(.roundedBorder) + } + GridRow { + Text("Host").frame(width: labelWidth, alignment: .trailing) + TextField("example.com", text: $host) + .textFieldStyle(.roundedBorder) + } + GridRow { + Text("User").frame(width: labelWidth, alignment: .trailing) + TextField("git", text: $user) + .textFieldStyle(.roundedBorder) + } + GridRow { + Text("Port").frame(width: labelWidth, alignment: .trailing) + TextField("22", text: $portString) + .textFieldStyle(.roundedBorder) + .frame(width: 80) + } + GridRow { + Text("Base Path").frame(width: labelWidth, alignment: .trailing) + TextField("~", text: $basePath) + .textFieldStyle(.roundedBorder) + } + } + + Text("Gitify uses your SSH config and keys. No credentials are stored.") + .font(.caption).foregroundStyle(.secondary) + + if let testResult { + HStack(spacing: 6) { + Image(systemName: testPassed ? "checkmark.circle.fill" : "xmark.circle.fill") + .foregroundStyle(testPassed ? .green : .red) + Text(testResult) + .font(.caption) + .foregroundStyle(testPassed ? Color.primary : Color.red) + .lineLimit(2) + } + } + + Spacer() + + HStack { + Button(testing ? "Testing\u{2026}" : "Test Connection") { + Task { await testConnection() } + } + .disabled(!isValid || testing) + Spacer() + Button("Cancel") { dismiss() }.keyboardShortcut(.cancelAction) + Button("Add") { addServer() } + .keyboardShortcut(.defaultAction) + .disabled(!isValid) + } + } + .padding(24) + .frame(width: 460, height: 380) + } + + private func testConnection() async { + let server = buildServer() + testing = true + testResult = nil + defer { testing = false } + if let error = await SSHScanner.testConnection(server) { + testResult = error + testPassed = false + } else { + testResult = "Connection successful." + testPassed = true + } + } + + private func addServer() { + let server = buildServer() + model.addSSHServer(server) + dismiss() + } + + private func buildServer() -> SSHServer { + let effectiveLabel = label.trimmingCharacters(in: .whitespaces) + let effectiveHost = host.trimmingCharacters(in: .whitespaces) + return SSHServer( + label: effectiveLabel.isEmpty ? effectiveHost : effectiveLabel, + host: effectiveHost, + user: user.trimmingCharacters(in: .whitespaces), + port: port, + basePath: basePath.trimmingCharacters(in: .whitespaces) + ) + } +} diff --git a/Sources/Gitify/Views/RepositoryWorkspaceView.swift b/Sources/Gitify/Views/RepositoryWorkspaceView.swift index adc07d7..2fdb00a 100644 --- a/Sources/Gitify/Views/RepositoryWorkspaceView.swift +++ b/Sources/Gitify/Views/RepositoryWorkspaceView.swift @@ -277,6 +277,7 @@ private struct RepoSwitcher: View { let model: AppModel let current: RepositoryRef @State private var showAccounts = false + @State private var showSSHServers = false var body: some View { Menu { @@ -296,9 +297,10 @@ private struct RepoSwitcher: View { Button("Clone Repository…") { Task { await model.promptToClone() } } Divider() Button("Accounts…") { showAccounts = true } + Button("SSH Servers…") { showSSHServers = true } } label: { HStack(spacing: 8) { - Image(systemName: "shippingbox.fill") + Image(systemName: current.isRemote ? "cloud.fill" : "shippingbox.fill") .foregroundStyle(.tint) Text(current.name) .fontWeight(.semibold) @@ -317,6 +319,7 @@ private struct RepoSwitcher: View { .padding(.top, 8) .padding(.bottom, 6) .sheet(isPresented: $showAccounts) { AccountsView(model: model) } + .sheet(isPresented: $showSSHServers) { SSHServersView(model: model) } } } @@ -793,6 +796,7 @@ private struct ErrorBanner: View { .frame(maxWidth: .infinity) .background(.red.opacity(0.15)) .overlay(Rectangle().frame(height: 1).foregroundStyle(.red.opacity(0.3)), alignment: .bottom) + .clipped() } } @@ -831,5 +835,6 @@ private struct OperationBanner: View { .frame(maxWidth: .infinity) .background(.orange.opacity(0.15)) .overlay(Rectangle().frame(height: 1).foregroundStyle(.orange.opacity(0.4)), alignment: .bottom) + .clipped() } } diff --git a/Sources/Gitify/Views/RootView.swift b/Sources/Gitify/Views/RootView.swift index cd8ee85..5971bd5 100644 --- a/Sources/Gitify/Views/RootView.swift +++ b/Sources/Gitify/Views/RootView.swift @@ -4,6 +4,7 @@ import SwiftUI /// sidebar + detail split), or an empty state when no repository is open. struct RootView: View { @Environment(AppModel.self) private var model + @State private var showSSHServers = false var body: some View { Group { @@ -19,6 +20,7 @@ struct RootView: View { } actions: { Button("Add Repository…") { Task { await model.promptToAddRepository() } } Button("Clone Repository…") { Task { await model.promptToClone() } } + Button("SSH Servers…") { showSSHServers = true } } } } @@ -40,5 +42,6 @@ struct RootView: View { } } } + .sheet(isPresented: $showSSHServers) { SSHServersView(model: model) } } } diff --git a/Sources/Gitify/Views/SSHServersView.swift b/Sources/Gitify/Views/SSHServersView.swift new file mode 100644 index 0000000..306f70a --- /dev/null +++ b/Sources/Gitify/Views/SSHServersView.swift @@ -0,0 +1,161 @@ +import SwiftUI + +/// Browse SSH servers, discover repositories, and clone them. +/// Layout follows the same `NavigationSplitView` pattern as `AccountsView`. +struct SSHServersView: View { + let model: AppModel + @Environment(\.dismiss) private var dismiss + + @State private var selection: SSHServer.ID? + @State private var repos: [SSHRepo] = [] + @State private var loading = false + @State private var error: String? + @State private var search = "" + @State private var addingServer = false + + private var selectedServer: SSHServer? { + model.sshServers.first { $0.id == selection } + } + private var filteredRepos: [SSHRepo] { + let q = search.trimmingCharacters(in: .whitespaces).lowercased() + return q.isEmpty ? repos : repos.filter { + $0.name.lowercased().contains(q) || $0.path.lowercased().contains(q) + } + } + + var body: some View { + NavigationSplitView { + List(selection: $selection) { + Section("SSH Servers") { + ForEach(model.sshServers) { server in + Label { + VStack(alignment: .leading, spacing: 1) { + Text(server.label) + Text("\(server.user)@\(server.host)") + .font(.caption).foregroundStyle(.secondary) + } + } icon: { + Image(systemName: "server.rack") + } + .tag(server.id) + .contextMenu { + Button("Remove Server", role: .destructive) { + model.removeSSHServer(server) + } + } + } + } + } + .safeAreaInset(edge: .bottom) { + HStack { + Button { addingServer = true } label: { + Label("Add Server", systemImage: "plus") + } + .buttonStyle(.borderless) + Spacer() + } + .padding(.horizontal, 12).padding(.vertical, 8) + .background(.bar) + } + .navigationSplitViewColumnWidth(min: 200, ideal: 220, max: 280) + } detail: { + detail + } + .frame(width: 760, height: 520) + .toolbar { + ToolbarItem(placement: .confirmationAction) { + Button("Done") { dismiss() } + } + } + .sheet(isPresented: $addingServer) { AddSSHServerSheet(model: model) } + .onChange(of: selection) { _, _ in Task { await loadRepos() } } + } + + @ViewBuilder + private var detail: some View { + if let server = selectedServer { + VStack(spacing: 0) { + HStack(spacing: 6) { + SearchField(text: $search, prompt: "Filter repositories") + Button { Task { await loadRepos() } } label: { + Image(systemName: "arrow.clockwise") + } + .buttonStyle(.borderless) + .help("Rescan for repositories") + .disabled(loading) + .padding(.trailing, 8) + } + if loading { + ProgressView("Scanning for repositories\u{2026}") + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else if let error { + VStack(spacing: 8) { + Spacer().frame(height: 40) + Image(systemName: "exclamationmark.triangle") + .font(.system(size: 36)) + .foregroundStyle(.orange) + Text("Couldn\u{2019}t Scan Server") + .font(.title3).foregroundStyle(.secondary) + Text(error) + .font(.caption).foregroundStyle(.secondary) + .textSelection(.enabled) + .multilineTextAlignment(.center) + .padding(.horizontal, 24) + Spacer() + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else if repos.isEmpty { + VStack(spacing: 8) { + Spacer().frame(height: 40) + Image(systemName: "shippingbox") + .font(.system(size: 36)) + .foregroundStyle(.secondary) + Text("No Repositories Found") + .font(.title3).foregroundStyle(.secondary) + Text("No git repositories were found under \u{201c}\(server.basePath)\u{201d}.") + .font(.caption).foregroundStyle(.tertiary) + Spacer() + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else { + List(filteredRepos) { repo in + HStack { + Image(systemName: "shippingbox").foregroundStyle(.secondary) + VStack(alignment: .leading, spacing: 1) { + Text(repo.name) + Text(repo.path) + .font(.caption).foregroundStyle(.secondary) + .lineLimit(1).truncationMode(.middle) + } + Spacer() + Button("Open") { + Task { await model.openRemote(repo, server: server); dismiss() } + } + Button("Clone") { + Task { await model.clone(repo); dismiss() } + } + } + } + .listStyle(.inset) + } + } + } else { + ContentUnavailableView( + "No Server Selected", + systemImage: "server.rack", + description: Text("Add or select an SSH server to browse its repositories.") + ) + } + } + + private func loadRepos() async { + guard let server = selectedServer else { return } + loading = true; error = nil; repos = [] + defer { loading = false } + do { + repos = try await SSHScanner.discoverRepositories(server) + } catch { + self.error = error.localizedDescription + } + } +} diff --git a/Sources/Gitify/Views/WorkingTreeView.swift b/Sources/Gitify/Views/WorkingTreeView.swift index 98f7ed4..c956fb8 100644 --- a/Sources/Gitify/Views/WorkingTreeView.swift +++ b/Sources/Gitify/Views/WorkingTreeView.swift @@ -127,9 +127,11 @@ struct WorkingTreeView: View { private func fileList(_ status: WorkingTreeStatus) -> some View { // Build a flat ordered list of FileSelection entries for shift-click range computation. + let conflictedFiles = status.conflictedFiles.filter(matches) let stagedFiles = status.stagedFiles.filter(matches) let unstagedFiles = status.unstagedFiles.filter(matches) let orderedSelections: [FileSelection] = + conflictedFiles.map { FileSelection(path: $0.path, staged: false) } + stagedFiles.map { FileSelection(path: $0.path, staged: true) } + unstagedFiles.map { FileSelection(path: $0.path, staged: false) } @@ -138,6 +140,24 @@ struct WorkingTreeView: View { let unstagedPeers = selectedUnstagedFiles(in: status) return List { + if !conflictedFiles.isEmpty { + Section { + ForEach(conflictedFiles) { file in + ConflictFileRow(file: file, viewModel: viewModel, + isSelected: selection.contains(FileSelection(path: file.path, staged: false)), + onResolveInEditor: { conflictTarget = ConflictTarget(path: file.path) }, + onClick: { mods in + handleRowClick(FileSelection(path: file.path, staged: false), + file: file, orderedSelections: orderedSelections, modifiers: mods) + }) + } + } header: { + sectionHeader("Conflicts", count: conflictedFiles.count) { + Text("resolve before committing") + .font(.caption).foregroundStyle(.orange) + } + } + } if !stagedFiles.isEmpty { Section { ForEach(stagedFiles) { file in @@ -361,6 +381,57 @@ private struct StagingFileRow: View { } } +/// A conflicted-file row with always-visible resolution actions, so a merge conflict is +/// obvious and fixable without discovering a hidden context menu. +private struct ConflictFileRow: View { + let file: FileStatus + @Bindable var viewModel: RepositoryViewModel + let isSelected: Bool + var onResolveInEditor: () -> Void + var onClick: (NSEvent.ModifierFlags) -> Void + + var body: some View { + HStack(spacing: 8) { + StatusBadge(file: file) + VStack(alignment: .leading, spacing: 1) { + Text(URL(fileURLWithPath: file.path).lastPathComponent) + Text(conflictDescription).font(.caption).foregroundStyle(.orange).lineLimit(1) + } + Spacer() + Button("Resolve…") { onResolveInEditor() } + .buttonStyle(.borderless).font(.caption) + Menu { + Button("Take Ours (current branch)") { Task { await viewModel.resolveConflict(file, useOurs: true) } } + Button("Take Theirs (incoming)") { Task { await viewModel.resolveConflict(file, useOurs: false) } } + Divider() + Button("Mark Resolved") { Task { await viewModel.markResolved(file) } } + } label: { + Image(systemName: "ellipsis.circle") + } + .menuStyle(.borderlessButton) + .fixedSize() + } + .padding(.vertical, 2) + .listRowBackground(isSelected ? Color.accentColor.opacity(0.2) : Color.clear) + .contentShape(Rectangle()) + .onTapGesture { onClick(currentModifiers()) } + } + + /// Human-readable conflict kind derived from the unmerged XY state pair. + private var conflictDescription: String { + switch (file.indexState, file.worktreeState) { + case (.deleted, .deleted): return "both deleted" + case (.added, .updatedButUnmerged): return "added by us" + case (.updatedButUnmerged, .added): return "added by them" + case (.deleted, .updatedButUnmerged): return "deleted by us" + case (.updatedButUnmerged, .deleted): return "deleted by them" + case (.added, .added): return "both added" + case (.updatedButUnmerged, .updatedButUnmerged): return "both modified" + default: return "conflicted" + } + } +} + /// Colored M/A/D/R/?? badge mirroring git's status letters. struct StatusBadge: View { let file: FileStatus diff --git a/Tests/GitKitChecks/Checks.swift b/Tests/GitKitChecks/Checks.swift index 0550345..1d08f0a 100644 --- a/Tests/GitKitChecks/Checks.swift +++ b/Tests/GitKitChecks/Checks.swift @@ -37,6 +37,12 @@ enum Checks { await test("stash branch, config get/set, conflict resolution", stashConfigConflict) await test("submodules are listed and parsed", submodules) await test("conflict markers parse; resolved file stages", conflictEditor) + await test("ahead/behind counts with configured upstream", aheadBehindUpstream) + await test("ahead/behind inferred without upstream config", aheadBehindInferred) + await test("push clears ahead count", pushClearsAhead) + await test("pull clears behind count", pullClearsBehind) + await test("fetch updates behind count from remote changes", fetchUpdatesBehind) + await test("diverged branch shows both ahead and behind", divergedBranch) } static func conflictEditor() async throws { @@ -758,4 +764,232 @@ enum Checks { await expect(FileManager.default.fileExists(atPath: cloned.appendingPathComponent("f.txt").path), "cloned working tree has file") } + + // MARK: - Push/pull indicator tests + + /// Helper: creates a repo with a local bare remote, pushes main with --set-upstream, + /// and returns (repo, service, bareURL). Caller must clean up `bareURL`. + private static func repoWithRemote() async throws -> (TestRepository, CLIGitService, URL) { + let repo = try await TestRepository() + try await repo.commit("initial", file: "f.txt", contents: "hello\n") + let service = try await repo.service() + + let bare = FileManager.default.temporaryDirectory + .appendingPathComponent("indicator-" + UUID().uuidString + ".git") + try await GitRunner(workingDirectory: nil).run(["init", "--bare", "-q", bare.path]) + + try await repo.git("remote", "add", "origin", bare.path) + try await service.push(remote: "origin", branch: "main", setUpstream: true, + force: false, onProgress: nil) + return (repo, service, bare) + } + + static func aheadBehindUpstream() async throws { + let (repo, service, bare) = try await repoWithRemote() + defer { try? FileManager.default.removeItem(at: bare) } + + // After initial push with --set-upstream, main should be even (ahead=0, behind=0). + var refs = try await service.refs() + var main = try await require(refs.first { $0.isHead && $0.name == "main" }, + "main branch found") + await expectEqual(main.upstream, "origin/main", "upstream is configured") + // When even, git\u{2019}s %(upstream:track) is empty so the parser returns nil. + await expect(main.ahead == nil, "even after push: ahead is nil") + await expect(main.behind == nil, "even after push: behind is nil") + + // Make two local commits \u{2014} ahead should increase to 2. + try await repo.commit("local-1", file: "a.txt", contents: "1\n") + try await repo.commit("local-2", file: "b.txt", contents: "2\n") + + refs = try await service.refs() + main = try await require(refs.first { $0.isHead && $0.name == "main" }, + "main after local commits") + await expectEqual(main.ahead, 2, "two commits ahead") + await expect(main.behind == nil, "still nil behind") + } + + static func aheadBehindInferred() async throws { + let (repo, service, bare) = try await repoWithRemote() + defer { try? FileManager.default.removeItem(at: bare) } + + // Create a branch without configuring its upstream tracking. + try await repo.git("checkout", "-q", "-b", "feature") + // Push without --set-upstream so no tracking config is written. + try await service.push(remote: "origin", branch: "feature", setUpstream: false, + force: false, onProgress: nil) + // Fetch so the remote ref is known locally. + try await service.fetch(remote: "origin", onProgress: nil) + + // Make a local commit on feature. + try await repo.commit("feature work", file: "feat.txt", contents: "work\n") + + let refs = try await service.refs() + let feature = try await require(refs.first { $0.isHead && $0.name == "feature" }, + "feature branch found") + // No configured upstream, but refs() should infer ahead/behind from origin/feature. + await expect(feature.upstream == nil, "no configured upstream") + await expectEqual(feature.ahead, 1, "inferred 1 ahead of origin/feature") + await expectEqual(feature.behind, 0, "inferred 0 behind origin/feature") + } + + static func pushClearsAhead() async throws { + let (repo, service, bare) = try await repoWithRemote() + defer { try? FileManager.default.removeItem(at: bare) } + + // Create local commits. + try await repo.commit("to-push-1", file: "p1.txt", contents: "1\n") + try await repo.commit("to-push-2", file: "p2.txt", contents: "2\n") + + // Verify ahead before push. + var refs = try await service.refs() + var main = try await require(refs.first { $0.isHead }, "HEAD before push") + await expectEqual(main.ahead, 2, "2 ahead before push") + + // Push and re-check \u{2014} ahead should drop to nil (even). + try await service.push(remote: nil, branch: nil, setUpstream: false, + force: false, onProgress: nil) + // Fetch to update remote tracking refs. + try await service.fetch(remote: "origin", onProgress: nil) + + refs = try await service.refs() + main = try await require(refs.first { $0.isHead }, "HEAD after push") + await expect(main.ahead == nil, "ahead cleared after push") + await expect(main.behind == nil, "behind still nil after push") + } + + static func pullClearsBehind() async throws { + let (repo, service, bare) = try await repoWithRemote() + defer { try? FileManager.default.removeItem(at: bare) } + + // Simulate remote-side commits by cloning the bare repo, committing there, + // and pushing back. + let otherDir = FileManager.default.temporaryDirectory + .appendingPathComponent("other-" + UUID().uuidString) + try FileManager.default.createDirectory(at: otherDir, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: otherDir) } + + let otherURL = try await CLIGitService.clone(url: bare.path, into: otherDir, + name: "clone", onProgress: nil) + let otherRunner = GitRunner(workingDirectory: otherURL) + try await otherRunner.run(["config", "user.name", "Other"]) + try await otherRunner.run(["config", "user.email", "other@example.com"]) + try await otherRunner.run(["config", "commit.gpgsign", "false"]) + try "remote change 1\n".write(to: otherURL.appendingPathComponent("r1.txt"), + atomically: true, encoding: .utf8) + try await otherRunner.run(["add", "r1.txt"]) + try await otherRunner.run(["commit", "-q", "-m", "remote-1"]) + try "remote change 2\n".write(to: otherURL.appendingPathComponent("r2.txt"), + atomically: true, encoding: .utf8) + try await otherRunner.run(["add", "r2.txt"]) + try await otherRunner.run(["commit", "-q", "-m", "remote-2"]) + try await otherRunner.run(["push", "origin", "main"]) + + // Fetch in our repo so we see the remote commits. + try await service.fetch(remote: "origin", onProgress: nil) + + var refs = try await service.refs() + var main = try await require(refs.first { $0.isHead }, "HEAD after fetch") + await expectEqual(main.behind, 2, "2 behind after fetch") + await expect(main.ahead == nil, "0 ahead") + + // Pull should bring us even. + try await service.pull(rebase: false, noRebase: false, onProgress: nil) + + refs = try await service.refs() + main = try await require(refs.first { $0.isHead }, "HEAD after pull") + await expect(main.behind == nil, "behind cleared after pull") + await expect(main.ahead == nil, "ahead still nil after pull") + + // Verify the files actually arrived. + await expect(FileManager.default.fileExists(atPath: + repo.url.appendingPathComponent("r1.txt").path), "pulled file r1 exists") + await expect(FileManager.default.fileExists(atPath: + repo.url.appendingPathComponent("r2.txt").path), "pulled file r2 exists") + } + + static func fetchUpdatesBehind() async throws { + let (repo, service, bare) = try await repoWithRemote() + _ = repo // prevent deinit from cleaning up the temp directory + defer { try? FileManager.default.removeItem(at: bare) } + + // Before any remote changes, behind should be nil (even). + var refs = try await service.refs() + var main = try await require(refs.first { $0.isHead }, "HEAD before remote changes") + await expect(main.behind == nil, "initially nil behind (even)") + + // Push a commit to the bare remote from a second clone. + let otherDir = FileManager.default.temporaryDirectory + .appendingPathComponent("fetcher-" + UUID().uuidString) + try FileManager.default.createDirectory(at: otherDir, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: otherDir) } + + let otherURL = try await CLIGitService.clone(url: bare.path, into: otherDir, + name: "clone", onProgress: nil) + let otherRunner = GitRunner(workingDirectory: otherURL) + try await otherRunner.run(["config", "user.name", "Other"]) + try await otherRunner.run(["config", "user.email", "other@example.com"]) + try await otherRunner.run(["config", "commit.gpgsign", "false"]) + try "fetched content\n".write(to: otherURL.appendingPathComponent("remote.txt"), + atomically: true, encoding: .utf8) + try await otherRunner.run(["add", "remote.txt"]) + try await otherRunner.run(["commit", "-q", "-m", "remote commit"]) + try await otherRunner.run(["push", "origin", "main"]) + + // Before fetch, our repo doesn\u{2019}t know about the remote commit yet. + refs = try await service.refs() + main = try await require(refs.first { $0.isHead }, "HEAD before fetch") + await expect(main.behind == nil, "behind still nil before fetch") + + // After fetch, behind should update to 1. + try await service.fetch(remote: "origin", onProgress: nil) + + refs = try await service.refs() + main = try await require(refs.first { $0.isHead }, "HEAD after fetch") + await expectEqual(main.behind, 1, "behind is 1 after fetch") + await expect(main.ahead == nil, "ahead unchanged (nil = even)") + } + + static func divergedBranch() async throws { + let (repo, service, bare) = try await repoWithRemote() + defer { try? FileManager.default.removeItem(at: bare) } + + // Push a commit from a second clone to create remote-side divergence. + let otherDir = FileManager.default.temporaryDirectory + .appendingPathComponent("diverge-" + UUID().uuidString) + try FileManager.default.createDirectory(at: otherDir, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: otherDir) } + + let otherURL = try await CLIGitService.clone(url: bare.path, into: otherDir, + name: "clone", onProgress: nil) + let otherRunner = GitRunner(workingDirectory: otherURL) + try await otherRunner.run(["config", "user.name", "Other"]) + try await otherRunner.run(["config", "user.email", "other@example.com"]) + try await otherRunner.run(["config", "commit.gpgsign", "false"]) + try "diverge remote\n".write(to: otherURL.appendingPathComponent("remote.txt"), + atomically: true, encoding: .utf8) + try await otherRunner.run(["add", "remote.txt"]) + try await otherRunner.run(["commit", "-q", "-m", "remote diverge"]) + try await otherRunner.run(["push", "origin", "main"]) + + // Make a local commit (diverging from the remote). + try await repo.commit("local diverge", file: "local.txt", contents: "local\n") + + // Fetch so we see the remote commit. + try await service.fetch(remote: "origin", onProgress: nil) + + var refs = try await service.refs() + var main = try await require(refs.first { $0.isHead }, "HEAD diverged") + await expectEqual(main.ahead, 1, "diverged: 1 ahead") + await expectEqual(main.behind, 1, "diverged: 1 behind") + + // Force push should clear ahead (and make remote match local). + try await service.push(remote: nil, branch: nil, setUpstream: false, + force: true, onProgress: nil) + try await service.fetch(remote: "origin", onProgress: nil) + + refs = try await service.refs() + main = try await require(refs.first { $0.isHead }, "HEAD after force push") + await expect(main.ahead == nil, "ahead cleared after force push") + await expect(main.behind == nil, "behind cleared after force push") + } }