From d4db59a883b87e50cd2f4402196ba5bc89a9e953 Mon Sep 17 00:00:00 2001 From: Thanh Tran Date: Wed, 23 Sep 2026 18:54:36 +0700 Subject: [PATCH 1/8] feat: implement git large file storage --- docs/git-lfs.md | 49 +++ .../2026-09-23-git-lfs-implementation-plan.md | 6 +- macgit/App/RevisionBrowserController.swift | 20 ++ .../App/RevisionBrowserWindowController.swift | 3 +- macgit/Models/GitLFSCandidate.swift | 8 + macgit/Models/GitLFSCloneRecoveryError.swift | 10 + macgit/Models/GitLFSConversionReview.swift | 10 + macgit/Models/GitLFSFile.swift | 13 + macgit/Models/GitLFSFileList.swift | 6 + macgit/Models/GitLFSPointer.swift | 22 ++ macgit/Models/GitLFSSnapshot.swift | 11 + macgit/Models/GitLFSTrackingReview.swift | 11 + macgit/Models/GitLFSTrackingRule.swift | 27 ++ macgit/Models/GitLFSTransferProgress.swift | 27 ++ macgit/Models/RevisionFilePreview.swift | 1 + macgit/Resources/GitLFS-LICENSE.txt | 55 ++++ macgit/Resources/GitLFS-NOTICE.txt | 4 + macgit/Services/GitCredentialInjector.swift | 12 + macgit/Services/GitLFSArchiveExtractor.swift | 37 +++ macgit/Services/GitLFSErrorMessage.swift | 40 +++ macgit/Services/GitLFSProgressReader.swift | 24 ++ macgit/Services/GitLFSRuntime.swift | 113 +++++++ macgit/Services/GitLFSVersionRunner.swift | 19 ++ macgit/Services/GitRuntimeManager.swift | 11 +- macgit/Services/GitStatusService+Clone.swift | 26 +- macgit/Services/GitStatusService+Commit.swift | 1 + macgit/Services/GitStatusService+LFS.swift | 250 +++++++++++++++ .../GitStatusService+RemoteCredential.swift | 7 +- .../GitStatusService+RevisionBrowser.swift | 28 +- macgit/Services/GitStatusService.swift | 64 +++- .../ViewModels/GitLFSRuntimeController.swift | 55 ++++ .../ViewModels/RepositoryLFSController.swift | 88 +++++ macgit/Views/Common/GitSettingsView.swift | 1 + macgit/Views/FileStatus/FileStatusView.swift | 9 + .../Views/History/RevisionBrowserView.swift | 18 +- .../LFS/GitLFSCredentialEnvironment.swift | 6 + macgit/Views/LFS/GitLFSDownloadControls.swift | 23 ++ .../LFS/GitLFSPreviewDownloadButton.swift | 36 +++ macgit/Views/LFS/GitLFSRuntimeSection.swift | 39 +++ macgit/Views/LFS/GitLFSView.swift | 301 ++++++++++++++++++ macgit/Views/MainWindow/ContentView.swift | 1 + macgit/Views/MainWindow/MainWindowView.swift | 16 +- macgit/Views/MainWindow/RepoPickerView.swift | 53 ++- .../MainWindow/Sidebar/SidebarItem.swift | 2 + .../MainWindow/Sidebar/SidebarSection.swift | 2 +- macgitTests/GitLFSIntegrationTests.swift | 139 ++++++++ macgitTests/GitLFSRuntimeTests.swift | 42 +++ macgitTests/GitLFSTests.swift | 95 ++++++ macgitTests/RevisionBrowserServiceTests.swift | 3 +- 49 files changed, 1812 insertions(+), 32 deletions(-) create mode 100644 docs/git-lfs.md create mode 100644 macgit/Models/GitLFSCandidate.swift create mode 100644 macgit/Models/GitLFSCloneRecoveryError.swift create mode 100644 macgit/Models/GitLFSConversionReview.swift create mode 100644 macgit/Models/GitLFSFile.swift create mode 100644 macgit/Models/GitLFSFileList.swift create mode 100644 macgit/Models/GitLFSPointer.swift create mode 100644 macgit/Models/GitLFSSnapshot.swift create mode 100644 macgit/Models/GitLFSTrackingReview.swift create mode 100644 macgit/Models/GitLFSTrackingRule.swift create mode 100644 macgit/Models/GitLFSTransferProgress.swift create mode 100644 macgit/Resources/GitLFS-LICENSE.txt create mode 100644 macgit/Resources/GitLFS-NOTICE.txt create mode 100644 macgit/Services/GitLFSArchiveExtractor.swift create mode 100644 macgit/Services/GitLFSErrorMessage.swift create mode 100644 macgit/Services/GitLFSProgressReader.swift create mode 100644 macgit/Services/GitLFSRuntime.swift create mode 100644 macgit/Services/GitLFSVersionRunner.swift create mode 100644 macgit/Services/GitStatusService+LFS.swift create mode 100644 macgit/ViewModels/GitLFSRuntimeController.swift create mode 100644 macgit/ViewModels/RepositoryLFSController.swift create mode 100644 macgit/Views/LFS/GitLFSCredentialEnvironment.swift create mode 100644 macgit/Views/LFS/GitLFSDownloadControls.swift create mode 100644 macgit/Views/LFS/GitLFSPreviewDownloadButton.swift create mode 100644 macgit/Views/LFS/GitLFSRuntimeSection.swift create mode 100644 macgit/Views/LFS/GitLFSView.swift create mode 100644 macgitTests/GitLFSIntegrationTests.swift create mode 100644 macgitTests/GitLFSRuntimeTests.swift create mode 100644 macgitTests/GitLFSTests.swift diff --git a/docs/git-lfs.md b/docs/git-lfs.md new file mode 100644 index 00000000..4d34a848 --- /dev/null +++ b/docs/git-lfs.md @@ -0,0 +1,49 @@ +# Git LFS in Commit+ + +Git LFS stores large file content separately from the small pointer committed to Git. Use it for versioned binary assets such as design files, video, and datasets. Keep generated files and caches in `.gitignore` instead. + +## Start using Git LFS + +1. Open **Git LFS** in the repository sidebar. +2. If a compatible runtime is missing, choose **Download & Continue**. Commit+ downloads the official Git LFS 3.8.0 archive for this Mac, checks its size and SHA-256, and installs a private copy. Homebrew and administrator access are not required. +3. Choose **Set Up Git LFS…** to configure this repository's filters and pre-push hook. +4. Open **Tracking Rules**, enter a pattern such as `*.psd`, review it, and apply. For an individual filename, enable **Exact filename**. You can also start from a file's **Track with Git LFS…** context menu in File Status. +5. Review and stage `.gitattributes` and the affected files in File Status, then commit and push normally. + +The setup action preserves custom hooks. Repositories with a custom `core.hooksPath` require manual integration using `git lfs install --local --manual`. Refresh after integrating the hook. Commit+ does not overwrite shared hooks. + +## Choose the runtime + +**Settings → Git → Git LFS Installation** offers Automatic, System, and Embedded independently of Git Runtime. Automatic prefers a compatible system installation. System Git LFS must be version 3.8 or later. Downloading Embedded LFS selects it after successful installation. Settings and binaries stay local to this Mac. + +The selected runtime is also used by clean/smudge filters and pre-push hooks, including when Embedded Git contains another `git-lfs` executable. A cancelled or failed installation preserves the previous runtime and preference. Refresh Git LFS Information after installing or changing a system executable externally. + +## Existing files and history + +Adding a rule does not rewrite earlier commits. To convert a file already stored as a normal Git blob, choose **Convert Existing File…** under Tracking Rules. Review **Convert & Stage**, then commit the staged pointer and relevant attributes changes. Commit+ rejects files with existing staged changes to preserve partial staging. + +This conversion does not shrink existing history. History migration, server-side file locking, cache pruning, and provider quota dashboards are outside this release. + +## Download and inspect content + +- **Download Missing** downloads content for the current checkout using the selected remote and existing LFS include/exclude settings. +- **Download Selected** uses an explicit file selection. Filenames that cannot be represented safely in the CLI's include-filter syntax are rejected rather than broadening the download. +- **Restore Content** materializes cached content without downloading. Modified files are preserved. +- File states describe local content, not proof that the remote has received an object. +- **History → Browse Repository at Revision** recognizes valid LFS pointers, previews verified cached content within the existing 2 MB limit, and offers **Download for Preview**. Downloads for old revisions only change the cache, never the working tree. + +The Files table supports search, sorting, state filtering, and multiple selection. Explicit downloads show per-file byte progress when reported by the CLI, with an indeterminate fallback. Tracking Rules lists each pattern and source; inherited and excluded rules remain read-only in the table. Find Large Files scans metadata on demand, excludes ignored files, and defaults to a 50 MiB suggestion threshold. That threshold is a UI suggestion, not a provider upload limit. + +## Clone and recovery + +Clone has a **Download LFS content** option. Git data is cloned first. If LFS setup or downloading fails, the completed clone is retained; choose **Open Cloned Repository**, **Retry LFS Download**, or **Download Git LFS & Continue** when a runtime is missing. Disabling LFS downloading leaves pointers for later setup/download. + +Ordinary push uses the repository's LFS pre-push hook. Background Git fetch does not trigger an additional LFS download. Authentication, network, quota, missing-object, and disk-space failures are surfaced with recovery guidance. Remote storage and bandwidth limits are controlled by the hosting provider. + +## Validation + +Automated coverage includes pointer validation, unusual filenames, include-filter safety, credential host isolation, custom hook preservation, concurrent edits, partial staging, local push/clone/download round trips, runtime selection, and verified Embedded installation. Integration tests use `COMMITPLUS_TEST_LFS` for a test executable; the installer test uses `COMMITPLUS_TEST_LFS_ARCHIVE` for the official archive matching the host architecture. Neither test downloads or changes the user's runtime preference. + +Interactive UI behavior and live provider authentication/quota responses require separate manual verification. Recursive submodule LFS setup/download is not coordinated by the root repository's LFS screen; open each submodule repository to manage its LFS state. + +Sources: [Git LFS](https://git-lfs.com/), [official command documentation](https://github.com/git-lfs/git-lfs/tree/v3.8.0/docs/man), [pinned runtime release](https://github.com/git-lfs/git-lfs/releases/tag/v3.8.0). diff --git a/docs/plans/2026-09-23-git-lfs-implementation-plan.md b/docs/plans/2026-09-23-git-lfs-implementation-plan.md index 71f1791e..a7bec857 100644 --- a/docs/plans/2026-09-23-git-lfs-implementation-plan.md +++ b/docs/plans/2026-09-23-git-lfs-implementation-plan.md @@ -1,6 +1,8 @@ # Git Large File Storage Implementation Plan for Commit+ -Date: 2026-09-23. Status: implementation proposal; no app changes yet. +Date: 2026-09-23. Status: first-release implementation added; manual UI and live-provider verification remain outstanding. + +Implementation reference: [Git LFS user guide](../git-lfs.md). The delivered UI keeps Git LFS visible in Workspace so new users can discover setup directly. Setup, tracking, and review are presented in that workspace rather than a separate three-page wizard. Explicit LFS downloads show file and byte progress through the CLI progress interface, with an indeterminate fallback. Tracking rules have a source-aware table and preserve raw output for inspection. Existing custom hooks require manual integration. History migration, locking, and pruning remain outside this release. ## 1. What is Git LFS? @@ -225,4 +227,4 @@ rtk proxy xcodebuild -project macgit.xcodeproj -scheme macgit -destination 'plat rtk git diff --check ``` -The test class name is proposed and will be created during implementation. The current task only adds this plan; no app build is required. +The test class name is proposed and will be created during implementation. The initial planning task required no build. Implementation validation is recorded in the implementation handoff and Git LFS user guide. diff --git a/macgit/App/RevisionBrowserController.swift b/macgit/App/RevisionBrowserController.swift index 1ea5787d..130f466d 100644 --- a/macgit/App/RevisionBrowserController.swift +++ b/macgit/App/RevisionBrowserController.swift @@ -6,6 +6,7 @@ import Observation final class RevisionBrowserController { let repositoryURL: URL let revision: String + var lfsCredentialResolver: GitProviderCredentialResolver? private(set) var snapshot: RevisionBrowserSnapshot? private(set) var children: [String: [RevisionTreeEntry]] = [:] private(set) var expanded: Set = [] @@ -131,6 +132,25 @@ final class RevisionBrowserController { } } + func downloadLFSPreview(remote: String) { + guard let entry = selectedEntry, let snapshot, preview?.lfsPointer != nil else { return } + previewTask?.cancel() + let id = previewID + isLoadingPreview = true + previewTask = Task { + do { + try await GitStatusService.shared.downloadLFSPreview(path: entry.path, revision: snapshot.commitID, remote: remote, in: repositoryURL, credentialResolver: lfsCredentialResolver) + let loaded = try await service.browserPreview(entry: entry, in: repositoryURL) + guard id == previewID, !Task.isCancelled else { return } + preview = loaded + } catch { + guard id == previewID, !Task.isCancelled else { return } + previewError = error.localizedDescription + } + if id == previewID { isLoadingPreview = false } + } + } + func folderTask(for path: String) -> Task? { folderTasks[path] } func cancel() { diff --git a/macgit/App/RevisionBrowserWindowController.swift b/macgit/App/RevisionBrowserWindowController.swift index 4c8073b7..614c5a25 100644 --- a/macgit/App/RevisionBrowserWindowController.swift +++ b/macgit/App/RevisionBrowserWindowController.swift @@ -15,9 +15,10 @@ final class RevisionBrowserWindowController: NSWindowController, NSWindowDelegat fatalError("init(coder:) has not been implemented") } - func show(revision: String, in repositoryURL: URL) { + func show(revision: String, in repositoryURL: URL, credentialResolver: GitProviderCredentialResolver? = nil) { close() let browser = RevisionBrowserController(repositoryURL: repositoryURL, revision: revision) + browser.lfsCredentialResolver = credentialResolver self.browser = browser let screen = NSApp.keyWindow?.screen ?? NSScreen.main let visibleFrame = screen?.visibleFrame ?? NSRect(x: 0, y: 0, width: 1200, height: 800) diff --git a/macgit/Models/GitLFSCandidate.swift b/macgit/Models/GitLFSCandidate.swift new file mode 100644 index 00000000..95fc110e --- /dev/null +++ b/macgit/Models/GitLFSCandidate.swift @@ -0,0 +1,8 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later +import Foundation + +nonisolated struct GitLFSCandidate: Identifiable, Sendable { + let path: String + let size: Int64 + var id: String { path } +} diff --git a/macgit/Models/GitLFSCloneRecoveryError.swift b/macgit/Models/GitLFSCloneRecoveryError.swift new file mode 100644 index 00000000..ad52f20b --- /dev/null +++ b/macgit/Models/GitLFSCloneRecoveryError.swift @@ -0,0 +1,10 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later +import Foundation + +struct GitLFSCloneRecoveryError: LocalizedError { + let repository: URL + let reason: String + var errorDescription: String? { + "Git clone completed, but Git LFS content is not ready. You can open the repository and finish setup/download in Git LFS.\n\n\(reason)" + } +} diff --git a/macgit/Models/GitLFSConversionReview.swift b/macgit/Models/GitLFSConversionReview.swift new file mode 100644 index 00000000..bef9cbec --- /dev/null +++ b/macgit/Models/GitLFSConversionReview.swift @@ -0,0 +1,10 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later +import Foundation + +nonisolated struct GitLFSConversionReview: Identifiable, Sendable { + let id = UUID() + let path: String + let contentHash: String + let indexEntry: String + let attributes: String +} diff --git a/macgit/Models/GitLFSFile.swift b/macgit/Models/GitLFSFile.swift new file mode 100644 index 00000000..3050956a --- /dev/null +++ b/macgit/Models/GitLFSFile.swift @@ -0,0 +1,13 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later +import Foundation + +nonisolated struct GitLFSFile: Decodable, Identifiable, Sendable { + let name: String + let size: Int64 + let checkout: Bool + let downloaded: Bool + let oid: String + var modification: String? = nil + var id: String { name } + var localState: String { modification ?? (checkout ? "Available" : downloaded ? "Cached · Restore Content" : "Not downloaded") } +} diff --git a/macgit/Models/GitLFSFileList.swift b/macgit/Models/GitLFSFileList.swift new file mode 100644 index 00000000..77be7d17 --- /dev/null +++ b/macgit/Models/GitLFSFileList.swift @@ -0,0 +1,6 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later +import Foundation + +nonisolated struct GitLFSFileList: Decodable, Sendable { + let files: [GitLFSFile]? +} diff --git a/macgit/Models/GitLFSPointer.swift b/macgit/Models/GitLFSPointer.swift new file mode 100644 index 00000000..c24a159b --- /dev/null +++ b/macgit/Models/GitLFSPointer.swift @@ -0,0 +1,22 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later +import Foundation + +nonisolated struct GitLFSPointer: Equatable, Sendable { + let oid: String + let size: Int64 + + init?(_ text: String) { + guard text.utf8.count <= 1024 else { return nil } + let lines = text.replacingOccurrences(of: "\r\n", with: "\n").split(separator: "\n") + guard lines.first == "version https://git-lfs.github.com/spec/v1" else { return nil } + let oids = lines.filter { $0.hasPrefix("oid sha256:") } + let sizes = lines.filter { $0.hasPrefix("size ") } + guard oids.count == 1, sizes.count == 1, + let size = Int64(sizes[0].dropFirst(5)), size >= 0 else { return nil } + let oid = String(oids[0].dropFirst(11)) + guard oid.count == 64, oid.utf8.allSatisfy({ (48...57).contains($0) || (97...102).contains($0) }), + lines.dropFirst().allSatisfy({ $0.hasPrefix("oid sha256:") || $0.hasPrefix("size ") || $0.hasPrefix("ext-") }) else { return nil } + self.oid = oid + self.size = size + } +} diff --git a/macgit/Models/GitLFSSnapshot.swift b/macgit/Models/GitLFSSnapshot.swift new file mode 100644 index 00000000..a8d71082 --- /dev/null +++ b/macgit/Models/GitLFSSnapshot.swift @@ -0,0 +1,11 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later +import Foundation + +nonisolated struct GitLFSSnapshot: Sendable { + let files: [GitLFSFile] + let rules: String + let branch: String + let remotes: [String] + let suggestedRemote: String? + let setupIssue: String? +} diff --git a/macgit/Models/GitLFSTrackingReview.swift b/macgit/Models/GitLFSTrackingReview.swift new file mode 100644 index 00000000..56e6a8ab --- /dev/null +++ b/macgit/Models/GitLFSTrackingReview.swift @@ -0,0 +1,11 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later +import Foundation + +nonisolated struct GitLFSTrackingReview: Identifiable, Sendable { + let id = UUID() + let pattern: String + let literal: Bool + let removing: Bool + let attributes: Data? + let preview: String +} diff --git a/macgit/Models/GitLFSTrackingRule.swift b/macgit/Models/GitLFSTrackingRule.swift new file mode 100644 index 00000000..5d1fb7e5 --- /dev/null +++ b/macgit/Models/GitLFSTrackingRule.swift @@ -0,0 +1,27 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later +import Foundation + +nonisolated struct GitLFSTrackingRule: Identifiable, Sendable { + let pattern: String + let source: String + let excluded: Bool + var id: String { source + "\0" + pattern + "\0" + String(excluded) } + var canRemove: Bool { source == ".gitattributes" && !excluded && !pattern.hasPrefix("\"") } + + /// Presentation only. Git remains authoritative for effective attributes and edits. + static func displayRules(_ listing: String) -> [Self] { + var excluded = false + var result: [Self] = [] + var seen = Set() + for line in listing.split(separator: "\n") { + if line == "Listing excluded patterns" { excluded = true; continue } + guard line.hasPrefix(" "), line.hasSuffix(")"), + let separator = line.range(of: " (", options: .backwards) else { continue } + let pattern = String(line.dropFirst(4)[.. 0, count >= index, + bytes >= 0, total > 0, bytes <= total else { return nil } + direction = String(fields[0]) + fileIndex = index + fileCount = count + self.bytes = bytes + totalBytes = total + name = String(fields[3]) + } +} diff --git a/macgit/Models/RevisionFilePreview.swift b/macgit/Models/RevisionFilePreview.swift index 372588eb..0906e30f 100644 --- a/macgit/Models/RevisionFilePreview.swift +++ b/macgit/Models/RevisionFilePreview.swift @@ -6,6 +6,7 @@ nonisolated struct RevisionFilePreview: Sendable { let lines: [DiffLine] let message: String? var imageData: Data? = nil + var lfsPointer: GitLFSPointer? = nil static func notice(_ message: String) -> Self { Self(text: nil, lines: [], message: message) diff --git a/macgit/Resources/GitLFS-LICENSE.txt b/macgit/Resources/GitLFS-LICENSE.txt new file mode 100644 index 00000000..90d98f12 --- /dev/null +++ b/macgit/Resources/GitLFS-LICENSE.txt @@ -0,0 +1,55 @@ +MIT License + +Copyright (c) 2014- GitHub, Inc. and Git LFS contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +Portions of the subprocess and tools directories are copied from Go and are +under the following license: + +Copyright (c) 2009,2010 The Go Authors. All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + + * Redistributions of source code must retain the above copyright +notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above +copyright notice, this list of conditions and the following disclaimer +in the documentation and/or other materials provided with the +distribution. + * Neither the name of Google Inc. nor the names of its +contributors may be used to endorse or promote products derived from +this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +Note that Git LFS uses components from other Go modules, which are under +different licenses. See those LICENSE files for details. diff --git a/macgit/Resources/GitLFS-NOTICE.txt b/macgit/Resources/GitLFS-NOTICE.txt new file mode 100644 index 00000000..acdba0bc --- /dev/null +++ b/macgit/Resources/GitLFS-NOTICE.txt @@ -0,0 +1,4 @@ +Git LFS +https://github.com/git-lfs/git-lfs + +Commit+ optionally downloads the official Git LFS 3.8.0 macOS binary into private application storage. Git LFS is distributed under the MIT license; see GitLFS-LICENSE.txt. The downloaded archive retains upstream documentation. diff --git a/macgit/Services/GitCredentialInjector.swift b/macgit/Services/GitCredentialInjector.swift index 992cbd88..bf66a7f6 100644 --- a/macgit/Services/GitCredentialInjector.swift +++ b/macgit/Services/GitCredentialInjector.swift @@ -104,6 +104,18 @@ struct TemporaryGitCredentialInjector: GitCredentialInjecting { private var helperScript: String { """ #!/bin/sh + if [ -n "$MACGIT_GIT_CREDENTIAL_HOST" ]; then + case "$1" in + *"$MACGIT_GIT_CREDENTIAL_SCHEME://"*) + authority=${1#*://} + authority=${authority%%/*} + authority=${authority%%\\'*} + authority=${authority##*@} + [ "$authority" = "$MACGIT_GIT_CREDENTIAL_HOST" ] || exit 1 + ;; + *) exit 1 ;; + esac + fi case "$1" in *sername*|*USERNAME*) /usr/bin/printf '%s\n' "$(/bin/cat "$MACGIT_GIT_USERNAME_FILE")" diff --git a/macgit/Services/GitLFSArchiveExtractor.swift b/macgit/Services/GitLFSArchiveExtractor.swift new file mode 100644 index 00000000..04be6d9e --- /dev/null +++ b/macgit/Services/GitLFSArchiveExtractor.swift @@ -0,0 +1,37 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later +import Foundation + +nonisolated struct GitLFSArchiveExtractor: GitRuntimeExtracting { + nonisolated static func validateListing(_ listing: String) throws { + let entries = listing.split(separator: "\n") + guard !entries.isEmpty, entries.allSatisfy({ entry in + !entry.hasPrefix("/") && !entry.split(separator: "/").contains("..") + && entry.hasPrefix("git-lfs-3.8.0/") + }) else { throw GitError.commandFailed("The Git LFS archive contains unsafe paths.") } + } + + func extract(archiveURL: URL, to destinationURL: URL) throws { + let listing = try runTar(["-tf", archiveURL.path]) + try Self.validateListing(listing) + let details = try runTar(["-tvf", archiveURL.path]) + guard details.split(separator: "\n").allSatisfy({ $0.hasPrefix("-") || $0.hasPrefix("d") }) else { + throw GitError.commandFailed("The Git LFS archive contains unsupported links.") + } + try FileManager.default.createDirectory(at: destinationURL, withIntermediateDirectories: true) + _ = try runTar(["-xf", archiveURL.path, "-C", destinationURL.path]) + } + + private func runTar(_ arguments: [String]) throws -> String { + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/bin/tar") + process.arguments = arguments + let pipe = Pipe() + process.standardOutput = pipe + process.standardError = FileHandle.nullDevice + try process.run() + let data = pipe.fileHandleForReading.readDataToEndOfFile() + process.waitUntilExit() + guard process.terminationStatus == 0 else { throw GitError.commandFailed("Could not unpack Git LFS.") } + return String(decoding: data, as: UTF8.self) + } +} diff --git a/macgit/Services/GitLFSErrorMessage.swift b/macgit/Services/GitLFSErrorMessage.swift new file mode 100644 index 00000000..8335ef9d --- /dev/null +++ b/macgit/Services/GitLFSErrorMessage.swift @@ -0,0 +1,40 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later +import Foundation + +nonisolated enum GitLFSErrorMessage { + static func sanitized(_ message: String) -> String { + var result = message + if let regex = try? NSRegularExpression(pattern: "https?://[^\\s]+") { + for match in regex.matches(in: result, range: NSRange(result.startIndex..., in: result)).reversed() { + guard let range = Range(match.range, in: result), var url = URLComponents(string: String(result[range])) else { continue } + url.user = nil + url.password = nil + url.query = nil + url.fragment = nil + result.replaceSubrange(range, with: url.string ?? "") + } + } + result = result.replacingOccurrences(of: "(?i)(authorization[:=]\\s*(?:bearer|basic)\\s+)[^\\s]+", with: "$1", options: .regularExpression) + return String(result.prefix(4000)) + } + + static func describe(_ error: Error) -> String { + let detail = sanitized(error.localizedDescription) + let lower = detail.lowercased() + let explanation: String + if lower.contains("401") || lower.contains("403") || lower.contains("authentication") || lower.contains("credentials") { + explanation = "Git LFS could not authenticate. Check the account for the LFS endpoint; it may differ from the Git remote." + } else if lower.contains("quota") || lower.contains("bandwidth") { + explanation = "The remote reported a Git LFS storage or bandwidth limit. Check the provider's usage settings." + } else if lower.contains("no space left") { + explanation = "There is not enough disk space for Git LFS content." + } else if lower.contains("could not resolve") || lower.contains("timed out") || lower.contains("network is unreachable") { + explanation = "Git LFS could not reach the remote. Check the connection and retry." + } else if lower.contains("object does not exist") || lower.contains("object not found") { + explanation = "The LFS object is missing from the remote. Its original uploader may need to push the content." + } else { + explanation = "Git LFS did not complete. Completed transfers are retained; retry after resolving the issue." + } + return explanation + "\n\n" + detail + } +} diff --git a/macgit/Services/GitLFSProgressReader.swift b/macgit/Services/GitLFSProgressReader.swift new file mode 100644 index 00000000..6905f1da --- /dev/null +++ b/macgit/Services/GitLFSProgressReader.swift @@ -0,0 +1,24 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later +import Foundation + +nonisolated enum GitLFSProgressReader { + static func observe(file: URL, update: @escaping @Sendable (GitLFSTransferProgress) -> Void) -> Task { + Task.detached(priority: .utility) { + guard let handle = try? FileHandle(forReadingFrom: file) else { return } + defer { try? handle.close() } + var pending = Data() + while !Task.isCancelled { + if let data = try? handle.read(upToCount: 65_536), !data.isEmpty { + pending.append(data) + while let newline = pending.firstIndex(of: 10) { + let line = String(decoding: pending[.. 65_536 { pending.removeAll() } + } + do { try await Task.sleep(for: .milliseconds(200)) } catch { return } + } + } + } +} diff --git a/macgit/Services/GitLFSRuntime.swift b/macgit/Services/GitLFSRuntime.swift new file mode 100644 index 00000000..5bf4b7f1 --- /dev/null +++ b/macgit/Services/GitLFSRuntime.swift @@ -0,0 +1,113 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later +import Foundation +import CryptoKit + +actor GitLFSRuntime { + static let shared = GitLFSRuntime() + let manager: GitRuntimeManager + private let commandDirectory: URL + private var cachedURL: URL? + private var didResolve = false + private var commandPaths: [String: URL] = [:] + + init(manager: GitRuntimeManager? = nil, commandDirectory: URL? = nil) { + self.commandDirectory = commandDirectory ?? FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0] + .appendingPathComponent("Commit+/GitLFS/CommandPaths") + self.manager = manager ?? GitRuntimeManager( + configuration: Self.configuration(), processRunner: GitLFSVersionRunner(), extractor: GitLFSArchiveExtractor() + ) + } + + nonisolated static func configuration() -> GitRuntimeConfiguration { + let candidates = (ProcessInfo.processInfo.environment["PATH"] ?? "").split(separator: ":").map(String.init) + + ["/opt/homebrew/bin", "/usr/local/bin", "/opt/local/bin"] + return GitRuntimeConfiguration( + applicationSupportDirectory: FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0], + candidateSystemGitURLs: candidates.map { URL(fileURLWithPath: $0).appendingPathComponent("git-lfs") }, + manifest: manifest, preferenceDefaults: .standard, preferenceKey: "gitLFSRuntimePreference", + managedDirectoryName: "GitLFS", executableRelativePath: "git-lfs-3.8.0/git-lfs", versionPrefix: "git-lfs/" + ) + } + + // Verified against the official git-lfs/git-lfs v3.8.0 release assets. + nonisolated static var manifest: GitRuntimeManifest { + #if arch(arm64) + let arch = "arm64" + let size = 5_550_634 + let checksum = "caff76a7d070d8160c89bc39b6e85d98f24135b6fed038a3b4de2590d25102d8" + #else + let arch = "amd64" + let size = 6_198_060 + let checksum = "f1c17aeca0b4eaab9ea606226477dbed3b84b56fe0811a9f967d2ea2b2393c53" + #endif + return GitRuntimeManifest(version: "3.8.0", platform: "macos-\(arch)", + url: URL(string: "https://github.com/git-lfs/git-lfs/releases/download/v3.8.0/git-lfs-darwin-\(arch)-v3.8.0.zip")!, + sha256: checksum, archiveSize: size) + } + + func status() async -> GitRuntimeStatus { + let status = await manager.status() + cachedURL = status.activeRuntime?.executableURL + didResolve = true + return status + } + + func executable() async throws -> URL { + if !didResolve { _ = await status() } + guard let cachedURL, FileManager.default.isExecutableFile(atPath: cachedURL.path) else { + throw GitError.commandFailed("Git LFS is unavailable. Open Git LFS to download Embedded Git LFS, or select System Git LFS in Settings.") + } + return cachedURL + } + + func environment(inheriting environment: [String: String]) async throws -> [String: String] { + if !didResolve { _ = await status() } + var result = environment + // Git prepends its exec-path ahead of PATH. Embedded Git includes its own + // git-lfs, so PATH alone cannot enforce the user's separate LFS choice. + if let corePath = environment["GIT_EXEC_PATH"] { + let key = corePath + "|" + (cachedURL?.path ?? "missing") + let commands: URL + if let cached = commandPaths[key] { commands = cached } + else { + let digest = SHA256.hash(data: Data(key.utf8)).map { String(format: "%02x", $0) }.joined() + let root = commandDirectory + commands = root.appendingPathComponent(digest) + let staging = root.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: staging, withIntermediateDirectories: true, attributes: [.posixPermissions: 0o700]) + defer { try? FileManager.default.removeItem(at: staging) } + for source in try FileManager.default.contentsOfDirectory(at: URL(fileURLWithPath: corePath), includingPropertiesForKeys: nil) where source.lastPathComponent != "git-lfs" { + try FileManager.default.createSymbolicLink(at: staging.appendingPathComponent(source.lastPathComponent), withDestinationURL: source) + } + let target = staging.appendingPathComponent("git-lfs") + if let cachedURL { + try FileManager.default.createSymbolicLink(at: target, withDestinationURL: cachedURL) + } else { + try "#!/bin/sh\necho 'Git LFS is unavailable. Open Git LFS in Commit+ to install it.' >&2\nexit 127\n".write(to: target, atomically: true, encoding: .utf8) + try FileManager.default.setAttributes([.posixPermissions: 0o700], ofItemAtPath: target.path) + } + if !FileManager.default.fileExists(atPath: commands.path) { + do { try FileManager.default.moveItem(at: staging, to: commands) } + catch { if !FileManager.default.fileExists(atPath: commands.path) { throw error } } + } + commandPaths[key] = commands + } + result["GIT_EXEC_PATH"] = commands.path + result["PATH"] = commands.path + ":" + (environment["PATH"] ?? "/usr/bin:/bin") + } else if let cachedURL { + result["PATH"] = cachedURL.deletingLastPathComponent().path + ":" + (environment["PATH"] ?? "/usr/bin:/bin") + } + return result + } + + func select(_ preference: GitRuntimePreference) async throws { + try await manager.setPreference(preference) + _ = await status() + } + + func install() async throws { + try await manager.installEmbeddedRuntime() + try Task.checkCancellation() + try await select(.embedded) + } +} diff --git a/macgit/Services/GitLFSVersionRunner.swift b/macgit/Services/GitLFSVersionRunner.swift new file mode 100644 index 00000000..edfb6a01 --- /dev/null +++ b/macgit/Services/GitLFSVersionRunner.swift @@ -0,0 +1,19 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later +import Foundation + +nonisolated struct GitLFSVersionRunner: GitRuntimeProcessRunning { + func version(at executableURL: URL) async throws -> String { + let version = try await ProcessGitRuntimeRunner().version(at: executableURL) + guard Self.isSupported(version) else { + throw GitError.commandFailed("Git LFS 3.8 or later is required. Download Embedded Git LFS to continue.") + } + return version + } + + nonisolated static func isSupported(_ version: String) -> Bool { + guard version.hasPrefix("git-lfs/"), let number = version.dropFirst(8).split(separator: " ").first else { return false } + let parts = number.split(separator: ".").compactMap { Int($0) } + guard parts.count >= 2 else { return false } + return parts[0] > 3 || (parts[0] == 3 && parts[1] >= 8) + } +} diff --git a/macgit/Services/GitRuntimeManager.swift b/macgit/Services/GitRuntimeManager.swift index bb9e01d6..cad74a96 100644 --- a/macgit/Services/GitRuntimeManager.swift +++ b/macgit/Services/GitRuntimeManager.swift @@ -36,6 +36,9 @@ struct GitRuntimeConfiguration: Sendable { let manifest: GitRuntimeManifest let preferenceDefaults: UserDefaults let preferenceKey: String + var managedDirectoryName: String = "Git" + var executableRelativePath: String = "bin/git" + var versionPrefix: String = "git version" static func live() -> GitRuntimeConfiguration { let supportDirectory = FileManager.default.urls( @@ -210,6 +213,7 @@ actor GitRuntimeManager { } defer { try? fileManager.removeItem(at: archiveURL) } + try Task.checkCancellation() let archiveSize = (try? archiveURL.resourceValues(forKeys: [.fileSizeKey]).fileSize) ?? 0 guard archiveSize == configuration.manifest.archiveSize else { throw GitRuntimeError.invalidArchiveSize( @@ -232,6 +236,7 @@ actor GitRuntimeManager { throw GitRuntimeError.validationFailed(stagedGitURL.path) } + try Task.checkCancellation() try promote(stagingURL: stagingURL, finalURL: managedRootURL()) cachedActiveRuntime = nil } @@ -261,7 +266,7 @@ actor GitRuntimeManager { private func validatedRuntime(at url: URL) async -> GitRuntimeInstallation? { guard fileManager.isExecutableFile(atPath: url.path), let version = try? await processRunner.version(at: url), - version.lowercased().contains("git version") else { + version.lowercased().hasPrefix(configuration.versionPrefix) else { return nil } return GitRuntimeInstallation( @@ -288,7 +293,7 @@ actor GitRuntimeManager { private func managedParentURL() -> URL { configuration.applicationSupportDirectory .appendingPathComponent("Commit+", isDirectory: true) - .appendingPathComponent("Git", isDirectory: true) + .appendingPathComponent(configuration.managedDirectoryName, isDirectory: true) } private func managedRootURL() -> URL { @@ -304,7 +309,7 @@ actor GitRuntimeManager { } private func gitURL(in rootURL: URL) -> URL { - rootURL.appendingPathComponent("bin/git") + rootURL.appendingPathComponent(configuration.executableRelativePath) } private func promote(stagingURL: URL, finalURL: URL) throws { diff --git a/macgit/Services/GitStatusService+Clone.swift b/macgit/Services/GitStatusService+Clone.swift index a15d5c67..3028497e 100644 --- a/macgit/Services/GitStatusService+Clone.swift +++ b/macgit/Services/GitStatusService+Clone.swift @@ -22,7 +22,9 @@ extension GitStatusService { remoteURL: String, to destinationURL: URL, checkoutBranch: String, - recurseSubmodules: Bool + recurseSubmodules: Bool, + downloadLFSContent: Bool = true, + credentialResolver: GitProviderCredentialResolver? = nil ) async throws { let parentURL = destinationURL.deletingLastPathComponent() var arguments = ["clone"] @@ -36,8 +38,26 @@ extension GitStatusService { arguments.append("--recurse-submodules") } - arguments += [remoteURL, destinationURL.path] - _ = try await runGit(arguments: arguments, in: parentURL) + arguments += ["--", remoteURL, destinationURL.path] + let injection = try await credentialInjection(for: remoteURL, in: parentURL, + credentialResolver: credentialResolver, credentialInjector: TemporaryGitCredentialInjector(), + sshCredentialInjector: TemporaryGitSSHCredentialInjector()) + defer { injection?.cleanup() } + var environment = injection?.environment ?? ProcessInfo.processInfo.environment + environment["GIT_LFS_SKIP_SMUDGE"] = "1" + // Clone Git data first so a failed LFS download never requires cloning again. + _ = try await runGit(arguments: ["-c", "filter.lfs.process=", "-c", "filter.lfs.smudge=", "-c", "filter.lfs.required=false"] + arguments, + in: parentURL, environment: environment) + let files = try await runGit(arguments: ["ls-files", "-z"], in: destinationURL) + let paths = files.split(separator: "\0").map(String.init) + guard try await !lfsPaths(paths, in: destinationURL).isEmpty else { return } + guard downloadLFSContent else { return } + do { + try await setupLFS(in: destinationURL) + try await downloadLFS(remote: "origin", in: destinationURL, credentialResolver: credentialResolver) + } catch { + throw GitLFSCloneRecoveryError(repository: destinationURL, reason: error.localizedDescription) + } } func remoteBranches(remoteURL: String) async throws -> [String] { diff --git a/macgit/Services/GitStatusService+Commit.swift b/macgit/Services/GitStatusService+Commit.swift index 413a6aba..bbe44ab2 100644 --- a/macgit/Services/GitStatusService+Commit.swift +++ b/macgit/Services/GitStatusService+Commit.swift @@ -91,6 +91,7 @@ extension GitStatusService { signOff: Bool = false, allowEmpty: Bool = false ) async throws { + try await validateLFSCommitAttributes(in: repositoryURL) var arguments = ["commit"] if allowEmpty { arguments.append("--allow-empty") } if message.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { diff --git a/macgit/Services/GitStatusService+LFS.swift b/macgit/Services/GitStatusService+LFS.swift new file mode 100644 index 00000000..ebf4fadf --- /dev/null +++ b/macgit/Services/GitStatusService+LFS.swift @@ -0,0 +1,250 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later +import Foundation + +extension GitStatusService { + func largeLFSCandidates(minimumBytes: Int64, in repository: URL) async throws -> [GitLFSCandidate] { + let output = try await runGit(arguments: ["ls-files", "-z", "--cached", "--others", "--exclude-standard"], in: repository) + let paths = Set(output.split(separator: "\0").map(String.init)) + guard paths.count <= 50_000 else { throw GitError.commandFailed("This repository is too large to scan at once. Track a filename or pattern directly.") } + var candidates: [GitLFSCandidate] = [] + for path in paths { + try Task.checkCancellation() + let url = repository.appendingPathComponent(path) + guard url.resolvingSymlinksInPath().path.hasPrefix(repository.resolvingSymlinksInPath().path + "/"), + let values = try? url.resourceValues(forKeys: [.fileSizeKey, .isRegularFileKey, .isSymbolicLinkKey]), + values.isRegularFile == true, values.isSymbolicLink != true, + let size = values.fileSize, size >= minimumBytes else { continue } + candidates.append(GitLFSCandidate(path: path, size: Int64(size))) + } + let tracked = try await lfsPaths(candidates.map(\.path), in: repository) + return candidates.filter { !tracked.contains($0.path) }.sorted { $0.size > $1.size } + } + + func lfsPaths(_ paths: [String], cached: Bool = false, in repository: URL) async throws -> Set { + var result = Set() + for offset in stride(from: 0, to: paths.count, by: 200) { + let batch = Array(paths[offset.. Void)? = nil) async throws -> String { + let executable = try await lfsRuntime.executable() + let context = try await gitExecutionContext(environment: environment ?? ProcessInfo.processInfo.environment) + var processEnvironment = context.environment + let progressFile = FileManager.default.temporaryDirectory.appendingPathComponent("commitplus-lfs-progress-\(UUID())") + var observer: Task? + if let onProgress, FileManager.default.createFile(atPath: progressFile.path, contents: Data(), attributes: [.posixPermissions: 0o600]) { + processEnvironment["GIT_LFS_PROGRESS"] = progressFile.path + observer = GitLFSProgressReader.observe(file: progressFile, update: onProgress) + } + defer { + observer?.cancel() + try? FileManager.default.removeItem(at: progressFile) + } + // Execute the selected binary directly; filters/hooks receive the same PATH. + do { + let output = try await runProcessRaw(executableURL: executable, arguments: arguments, in: repository, + environment: processEnvironment, outputByteLimit: 16_000_000) + return String(decoding: output, as: UTF8.self) + } catch { + if Task.isCancelled { throw CancellationError() } + throw GitError.commandFailed(GitLFSErrorMessage.describe(error)) + } + } + + func lfsSnapshot(in repository: URL) async throws -> GitLFSSnapshot { + let output = try await runLFS(["ls-files", "--json"], in: repository) + var files = try JSONDecoder().decode(GitLFSFileList.self, from: Data(output.utf8)).files ?? [] + let changed = try await runGit(arguments: ["diff", "--name-only", "-z", "--no-ext-diff", "--no-textconv"], in: repository) + let changedPaths = Set(changed.split(separator: "\0").map(String.init)) + let conflicts = try await runGit(arguments: ["diff", "--name-only", "--diff-filter=U", "-z"], in: repository) + let conflictPaths = Set(conflicts.split(separator: "\0").map(String.init)) + for index in files.indices { + if conflictPaths.contains(files[index].name) { files[index].modification = "Conflict" } + else if changedPaths.contains(files[index].name) { files[index].modification = "Modified" } + } + let rules = try await runLFS(["track"], in: repository) + let branch = await currentBranch(in: repository) ?? "Detached HEAD" + let remotes = await remotes(in: repository) + let upstream = try? await runGit(arguments: ["config", "--get", "branch.\(branch).remote"], in: repository).trimmingCharacters(in: .whitespacesAndNewlines) + let remote = upstream.flatMap { remotes.contains($0) ? $0 : nil } ?? (remotes.count == 1 ? remotes.first : nil) + let issue = try await lfsSetupIssue(in: repository) + return GitLFSSnapshot(files: files, rules: rules, branch: branch, remotes: remotes, suggestedRemote: remote, setupIssue: issue) + } + + private func lfsHookURL(in repository: URL) async throws -> URL { + let path = try await runGit(arguments: ["rev-parse", "--path-format=absolute", "--git-path", "hooks/pre-push"], in: repository) + return URL(fileURLWithPath: path.trimmingCharacters(in: .whitespacesAndNewlines), relativeTo: repository).standardizedFileURL + } + + func lfsSetupIssue(in repository: URL) async throws -> String? { + let hook = try await lfsHookURL(in: repository) + let contents = (try? String(contentsOf: hook, encoding: .utf8)) ?? "" + let filter = (try? await runGit(arguments: ["config", "--get", "filter.lfs.process"], in: repository)) ?? "" + guard filter.contains("git-lfs filter-process") else { return "Git LFS filters need setup in this repository." } + guard contents.contains("git lfs pre-push") || contents.contains("git-lfs pre-push") else { + return contents.isEmpty ? "The Git LFS pre-push hook is missing." : "An existing pre-push hook needs manual LFS integration. It will not be overwritten." + } + guard FileManager.default.isExecutableFile(atPath: hook.path) else { return "The pre-push hook is not executable." } + return nil + } + + func setupLFS(in repository: URL) async throws { + let key = try await acquireLFSMutation(in: repository) + defer { lfsMutations.remove(key) } + let configuredPath = (try? await runGit(arguments: ["config", "--get", "core.hooksPath"], in: repository)) ?? "" + let hook = try await lfsHookURL(in: repository) + if !configuredPath.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + throw GitError.commandFailed("This repository uses core.hooksPath. Integrate Git LFS with that hook manually (git lfs install --local --manual), then Refresh. Commit+ will not modify shared or custom hooks.") + } + let contents = (try? String(contentsOf: hook, encoding: .utf8)) ?? "" + if !contents.isEmpty && !contents.contains("git lfs pre-push") && !contents.contains("git-lfs pre-push") { + throw GitError.commandFailed("An existing pre-push hook needs manual integration. Run git lfs install --local --manual to see the required hook changes, then Refresh.") + } + _ = try await runLFS(["install", "--local"], in: repository) + } + + func reviewLFSTracking(pattern: String, literal: Bool, removing: Bool, in repository: URL) async throws -> GitLFSTrackingReview { + guard !pattern.isEmpty, !pattern.hasPrefix("-"), !pattern.contains("\n"), !pattern.contains("\r"), !pattern.contains("\0"), + !pattern.hasPrefix("/"), !pattern.split(separator: "/").contains("..") else { + throw GitError.commandFailed("Enter a repository-relative file or pattern, without newlines or a leading dash.") + } + let attributes = try readLFSAttributes(in: repository) + let preview: String + if removing { preview = "Remove the root tracking rule: \(pattern)\nExisting committed pointers and history will not be converted." } + else { + preview = try await runLFS(["track", "--dry-run"] + (literal ? ["--filename"] : []) + [pattern], in: repository) + } + return GitLFSTrackingReview(pattern: pattern, literal: literal, removing: removing, attributes: attributes, preview: preview) + } + + private func readLFSAttributes(in repository: URL) throws -> Data? { + let url = repository.appendingPathComponent(".gitattributes") + let values = try? url.resourceValues(forKeys: [.isSymbolicLinkKey, .fileSizeKey]) + guard values?.isSymbolicLink != true, (values?.fileSize ?? 0) < 2_000_000 else { + throw GitError.commandFailed("Open .gitattributes manually: it is a symbolic link or too large to edit safely.") + } + return FileManager.default.fileExists(atPath: url.path) ? try Data(contentsOf: url) : nil + } + + func applyLFSTracking(_ review: GitLFSTrackingReview, in repository: URL) async throws { + let key = try await acquireLFSMutation(in: repository) + defer { lfsMutations.remove(key) } + guard try readLFSAttributes(in: repository) == review.attributes else { + throw GitError.commandFailed(".gitattributes changed. Review the tracking rule again.") + } + _ = try await runLFS([review.removing ? "untrack" : "track"] + (review.literal ? ["--filename"] : []) + [review.pattern], in: repository) + } + + func downloadLFS(remote: String, in repository: URL, credentialResolver: GitProviderCredentialResolver?, paths: [String]? = nil, onProgress: (@Sendable (GitLFSTransferProgress) -> Void)? = nil) async throws { + let key = try await acquireLFSMutation(in: repository) + defer { lfsMutations.remove(key) } + guard !remote.isEmpty, !remote.hasPrefix("-"), await remotes(in: repository).contains(remote) else { + throw GitError.commandFailed("Choose an existing download remote.") + } + let injection = try await credentialInjection(for: remote, in: repository, credentialResolver: credentialResolver, + credentialInjector: TemporaryGitCredentialInjector(), sshCredentialInjector: TemporaryGitSSHCredentialInjector()) + defer { injection?.cleanup() } + var arguments = ["pull"] + if let paths { + arguments += ["--include=" + (try Self.lfsIncludePaths(paths)), "--exclude="] + } + arguments.append(remote) + _ = try await runLFS(arguments, in: repository, environment: injection?.environment, onProgress: onProgress) + } + + func restoreLFS(in repository: URL) async throws { + let key = try await acquireLFSMutation(in: repository) + defer { lfsMutations.remove(key) } + _ = try await runLFS(["checkout"], in: repository) + } + + nonisolated static func lfsIncludePaths(_ paths: [String]) throws -> String { + guard !paths.isEmpty, paths.allSatisfy({ path in + !path.isEmpty && !path.hasPrefix("/") && !path.hasPrefix("#") && !path.split(separator: "/").contains("..") + && path == path.trimmingCharacters(in: .whitespacesAndNewlines) + && !path.contains(where: { "*?[],!\\\n\r\0".contains($0) }) + }) else { + throw GitError.commandFailed("This selection contains filenames that cannot be represented by LFS include filters. Use Download Missing instead.") + } + return paths.map { "/" + $0 }.joined(separator: ",") + } + + func downloadLFSPreview(path: String, revision: String, remote: String, in repository: URL, credentialResolver: GitProviderCredentialResolver? = nil) async throws { + try Self.validateBrowserObjectID(revision) + let include = try Self.lfsIncludePaths([path]) + guard !remote.hasPrefix("-"), await remotes(in: repository).contains(remote) else { + throw GitError.commandFailed("This filename cannot be downloaded individually. Download its revision with Git LFS from Terminal.") + } + let key = try await acquireLFSMutation(in: repository) + defer { lfsMutations.remove(key) } + let injection = try await credentialInjection(for: remote, in: repository, credentialResolver: credentialResolver, + credentialInjector: TemporaryGitCredentialInjector(), sshCredentialInjector: TemporaryGitSSHCredentialInjector()) + defer { injection?.cleanup() } + // fetch changes only the cache, never the index or working tree. + var environment = injection?.environment ?? ProcessInfo.processInfo.environment + let count = Int(environment["GIT_CONFIG_COUNT"] ?? "") ?? 0 + environment["GIT_CONFIG_COUNT"] = String(count + 1) + environment["GIT_CONFIG_KEY_\(count)"] = "lfs.fetchrecentalways" + environment["GIT_CONFIG_VALUE_\(count)"] = "false" + _ = try await runLFS(["fetch", "--include=" + include, "--exclude=", remote, revision], in: repository, environment: environment) + } + + func reviewLFSConversion(path: String, in repository: URL) async throws -> GitLFSConversionReview { + guard !path.isEmpty, !path.hasPrefix("/"), !path.split(separator: "/").contains(".."), !path.contains("\0") else { + throw GitError.commandFailed("Select a file inside this repository.") + } + let file = repository.appendingPathComponent(path) + let root = repository.resolvingSymlinksInPath().path + "/" + let values = try file.resourceValues(forKeys: [.isRegularFileKey, .isSymbolicLinkKey]) + guard values.isRegularFile == true, values.isSymbolicLink != true, file.resolvingSymlinksInPath().path.hasPrefix(root) else { + throw GitError.commandFailed("Only regular files inside this repository can be converted.") + } + let attributes = try await runGit(arguments: ["check-attr", "-z", "filter", "--", path], in: repository) + guard attributes.split(separator: "\0").last == "lfs" else { + throw GitError.commandFailed("Add a Git LFS tracking rule for this file first.") + } + let staged = try await runGit(arguments: ["--literal-pathspecs", "diff", "--cached", "--name-only", "-z", "--", path], in: repository) + guard staged.isEmpty else { throw GitError.commandFailed("This file already has staged changes. Commit or unstage it before converting, so partial staging is preserved.") } + let index = try await runGit(arguments: ["--literal-pathspecs", "ls-files", "--stage", "-z", "--", path], in: repository) + guard !index.split(separator: "\0").contains(where: { !$0.contains(" 0\t") }) else { + throw GitError.commandFailed("Resolve this file's merge conflict before converting it.") + } + let hash = try await runGit(arguments: ["hash-object", "--no-filters", "--", path], in: repository) + return GitLFSConversionReview(path: path, contentHash: hash, indexEntry: index, attributes: attributes) + } + + func convertLFS(_ review: GitLFSConversionReview, in repository: URL) async throws { + let key = try await acquireLFSMutation(in: repository) + defer { lfsMutations.remove(key) } + let current = try await reviewLFSConversion(path: review.path, in: repository) + guard current.contentHash == review.contentHash, current.indexEntry == review.indexEntry, current.attributes == review.attributes else { + throw GitError.commandFailed("The file or index changed. Review the conversion again.") + } + _ = try await runGit(arguments: ["--literal-pathspecs", "add"] + (review.indexEntry.isEmpty ? [] : ["--renormalize"]) + ["--", review.path], in: repository) + } + + private func acquireLFSMutation(in repository: URL) async throws -> String { + let key = try await runGit(arguments: ["rev-parse", "--path-format=absolute", "--git-common-dir"], in: repository).trimmingCharacters(in: .whitespacesAndNewlines) + guard lfsMutations.insert(key).inserted else { throw GitError.commandFailed("Another Git LFS operation is running for this repository. Wait for it to finish.") } + return key + } +} diff --git a/macgit/Services/GitStatusService+RemoteCredential.swift b/macgit/Services/GitStatusService+RemoteCredential.swift index bdcf08ba..e5760508 100644 --- a/macgit/Services/GitStatusService+RemoteCredential.swift +++ b/macgit/Services/GitStatusService+RemoteCredential.swift @@ -60,11 +60,16 @@ extension GitStatusService { ) else { return nil } - return try injection( + var result = try injection( for: credential, credentialInjector: credentialInjector, sshCredentialInjector: sshCredentialInjector ) + if case .https = credential, let url = URLComponents(string: remoteURLString), let host = url.host { + result.environment["MACGIT_GIT_CREDENTIAL_HOST"] = host + (url.port.map { ":\($0)" } ?? "") + result.environment["MACGIT_GIT_CREDENTIAL_SCHEME"] = url.scheme ?? "https" + } + return result } func remoteCredential( diff --git a/macgit/Services/GitStatusService+RevisionBrowser.swift b/macgit/Services/GitStatusService+RevisionBrowser.swift index 85565736..545fbc64 100644 --- a/macgit/Services/GitStatusService+RevisionBrowser.swift +++ b/macgit/Services/GitStatusService+RevisionBrowser.swift @@ -1,6 +1,7 @@ // SPDX-License-Identifier: AGPL-3.0-or-later import AppKit import Foundation +import CryptoKit extension GitStatusService: RevisionBrowserServing { nonisolated static let revisionPreviewByteLimit = 2_000_000 @@ -70,9 +71,32 @@ extension GitStatusService: RevisionBrowserServing { let data = try await runGitRaw(arguments: ["cat-file", "blob", entry.objectID], in: repositoryURL, environment: ProcessInfo.processInfo.environment, outputByteLimit: Self.revisionPreviewByteLimit) try Task.checkCancellation() + if !entry.isSymlink, let text = String(data: data, encoding: .utf8), let pointer = GitLFSPointer(text) { + if pointer.size > Self.revisionPreviewByteLimit { + return .notice("Git LFS content: \(pointer.size) bytes\nSHA-256: \(pointer.oid)\nContent exceeds the 2 MB preview limit. Use Git LFS to download the current checkout.") + } + if let cached = try await cachedLFSData(pointer, in: repositoryURL) { + return try Self.decodeBrowserPreview(cached, isSymlink: false) + } + return RevisionFilePreview(text: text, lines: [], message: "Git LFS content is not available in the local cache.\nSize: \(pointer.size) bytes\nSHA-256: \(pointer.oid)", lfsPointer: pointer) + } return try Self.decodeBrowserPreview(data, isSymlink: entry.isSymlink) } + private func cachedLFSData(_ pointer: GitLFSPointer, in repository: URL) async throws -> Data? { + guard let environment = try? await runLFS(["env"], in: repository), + let line = environment.split(separator: "\n").first(where: { $0.hasPrefix("LocalMediaDir=") }) else { return nil } + let root = URL(fileURLWithPath: String(line.dropFirst("LocalMediaDir=".count))) + let url = root.appendingPathComponent(String(pointer.oid.prefix(2))) + .appendingPathComponent(String(pointer.oid.dropFirst(2).prefix(2))).appendingPathComponent(pointer.oid) + guard let handle = try? FileHandle(forReadingFrom: url) else { return nil } + defer { try? handle.close() } + let data = try handle.read(upToCount: Self.revisionPreviewByteLimit + 1) ?? Data() + guard data.count == pointer.size else { return nil } + let hash = SHA256.hash(data: data).map { String(format: "%02x", $0) }.joined() + return hash == pointer.oid ? data : nil + } + nonisolated static func decodeBrowserPreview(_ data: Data, isSymlink: Bool) throws -> RevisionFilePreview { if !isSymlink, NSImage(data: data) != nil { return RevisionFilePreview(text: nil, lines: [], message: nil, imageData: data) @@ -81,8 +105,8 @@ extension GitStatusService: RevisionBrowserServing { return .notice("Binary file or unsupported text encoding. UTF-8 preview is unavailable.") } if isSymlink { return RevisionFilePreview(text: text, lines: [], message: "Symbolic link target: \(text)") } - if text.hasPrefix("version https://git-lfs.github.com/spec/v1\n") || text.hasPrefix("version https://git-lfs.github.com/spec/v1\r\n") { - return RevisionFilePreview(text: text, lines: [], message: "Git LFS pointer (content is not downloaded):\n\(text)") + if let pointer = GitLFSPointer(text) { + return RevisionFilePreview(text: text, lines: [], message: "Git LFS pointer · \(pointer.size) bytes\nSHA-256: \(pointer.oid)", lfsPointer: pointer) } let rawLines = text.components(separatedBy: "\n") guard rawLines.count <= 50_000, rawLines.allSatisfy({ $0.utf8.count <= 16_000 }) else { diff --git a/macgit/Services/GitStatusService.swift b/macgit/Services/GitStatusService.swift index b873610b..5630a3d3 100644 --- a/macgit/Services/GitStatusService.swift +++ b/macgit/Services/GitStatusService.swift @@ -49,6 +49,7 @@ nonisolated private final class GitProcessExecution: @unchecked Sendable { private var stderrData = Data() private var continuation: CheckedContinuation? private var didResume = false + private var wasCancelled = false private var isOutputTruncated = false init( @@ -92,7 +93,9 @@ nonisolated private final class GitProcessExecution: @unchecked Sendable { self.stdout = stdout self.stderr = stderr self.continuation = continuation + let cancelledBeforeStart = wasCancelled lock.unlock() + if cancelledBeforeStart { resume(throwing: CancellationError()); return } outputGroup.enter() outputGroup.enter() @@ -106,6 +109,10 @@ nonisolated private final class GitProcessExecution: @unchecked Sendable { try task.run() drain(stdout.fileHandleForReading, intoStandardError: false) drain(stderr.fileHandleForReading, intoStandardError: true) + lock.lock() + let cancelled = wasCancelled + lock.unlock() + if cancelled { cancel() } } catch { stdout.fileHandleForWriting.closeFile() stderr.fileHandleForWriting.closeFile() @@ -162,7 +169,12 @@ nonisolated private final class GitProcessExecution: @unchecked Sendable { outputLock.unlock() let errorOutput = String(decoding: errData, as: UTF8.self) - if process.terminationStatus != 0 { + lock.lock() + let cancelled = wasCancelled + lock.unlock() + if cancelled { + resume(throwing: CancellationError()) + } else if process.terminationStatus != 0 { let output = String(decoding: outData, as: UTF8.self) let message = errorOutput.isEmpty ? output : errorOutput resume(throwing: GitError.commandFailed(message.trimmingCharacters(in: .whitespacesAndNewlines))) @@ -173,14 +185,24 @@ nonisolated private final class GitProcessExecution: @unchecked Sendable { private func cancel() { lock.lock() - let task = task - let shouldTerminate = task?.isRunning == true + wasCancelled = true + let process = task lock.unlock() + guard let process, process.isRunning else { return } + Self.terminateChildren(of: process.processIdentifier) + process.terminate() + // Completion waits for process exit and pipe draining before callers release credentials. + } - if shouldTerminate { - task?.terminate() + private static func terminateChildren(of pid: Int32) { + var children = [Int32](repeating: 0, count: 4096) + let capacity = Int32(children.count * MemoryLayout.size) + let byteCount = children.withUnsafeMutableBytes { proc_listchildpids(pid, $0.baseAddress, capacity) } + guard byteCount > 0 else { return } + for child in children.prefix(Int(byteCount) / MemoryLayout.size) where child > 0 && child != pid { + terminateChildren(of: child) + kill(child, SIGTERM) } - resume(throwing: CancellationError()) } private func resume(returning result: GitProcessResult) { @@ -217,14 +239,19 @@ actor GitStatusService { private let runner: (any GitCommandRunning)? let runtimeManager: GitRuntimeManager + let lfsRuntime: GitLFSRuntime let branchListCache = BranchListCache() + var lfsMutations = Set() + private var gitCorePaths: [String: String] = [:] init( runner: (any GitCommandRunning)? = nil, - runtimeManager: GitRuntimeManager = .shared + runtimeManager: GitRuntimeManager = .shared, + lfsRuntime: GitLFSRuntime = .shared ) { self.runner = runner self.runtimeManager = runtimeManager + self.lfsRuntime = lfsRuntime } func gitExecutable() async throws -> String { @@ -235,11 +262,23 @@ actor GitStatusService { environment: [String: String] = ProcessInfo.processInfo.environment ) async throws -> (executable: String, environment: [String: String]) { let executableURL = try await runtimeManager.executableURL() - let resolvedEnvironment = await runtimeManager.environment( + var resolvedEnvironment = await runtimeManager.environment( for: executableURL, inheriting: environment ) - return (executableURL.path, resolvedEnvironment) + resolvedEnvironment["PATH"] = executableURL.deletingLastPathComponent().path + ":" + (resolvedEnvironment["PATH"] ?? "/usr/bin:/bin") + if resolvedEnvironment["GIT_EXEC_PATH"] == nil { + if let cached = gitCorePaths[executableURL.path] { + resolvedEnvironment["GIT_EXEC_PATH"] = cached + } else { + let result = try await GitProcessExecution(executable: executableURL.path, arguments: ["--exec-path"], + directory: FileManager.default.temporaryDirectory, environment: resolvedEnvironment, outputByteLimit: 16_384).run() + let path = String(decoding: result.data, as: UTF8.self).trimmingCharacters(in: .whitespacesAndNewlines) + gitCorePaths[executableURL.path] = path + resolvedEnvironment["GIT_EXEC_PATH"] = path + } + } + return (executableURL.path, try await lfsRuntime.environment(inheriting: resolvedEnvironment)) } func runGit(arguments: [String], in directory: URL) async throws -> String { @@ -424,14 +463,17 @@ actor GitStatusService { executableURL: URL, arguments: [String], in directory: URL, - environment: [String: String] + environment: [String: String], + outputByteLimit: Int? = nil ) async throws -> Data { let result = try await GitProcessExecution( executable: executableURL.path, arguments: arguments, directory: directory, - environment: environment + environment: environment, + outputByteLimit: outputByteLimit ).run() + guard !result.isTruncated else { throw GitError.commandFailed("Command output exceeded the safety limit.") } return result.data } diff --git a/macgit/ViewModels/GitLFSRuntimeController.swift b/macgit/ViewModels/GitLFSRuntimeController.swift new file mode 100644 index 00000000..8b9635ed --- /dev/null +++ b/macgit/ViewModels/GitLFSRuntimeController.swift @@ -0,0 +1,55 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later +import Foundation +import Observation + +@MainActor @Observable +final class GitLFSRuntimeController { + static let shared = GitLFSRuntimeController() + var status: GitRuntimeStatus? + var isInstalling = false + var error: String? + private var installation: Task? + + var downloadDescription: String { + let manifest = GitLFSRuntime.manifest + return "Git LFS \(manifest.version) · \(ByteCountFormatter.string(fromByteCount: Int64(manifest.archiveSize), countStyle: .file))" + } + + func refresh() async { status = await GitLFSRuntime.shared.status() } + + func select(_ preference: GitRuntimePreference) async { + do { try await GitLFSRuntime.shared.select(preference); error = nil } + catch { self.error = Self.message(error) } + await refresh() + } + + @discardableResult + func install() async -> Bool { + if let installation { return await installation.value } + isInstalling = true + error = nil + let task = Task { () -> Bool in + do { + try await GitLFSRuntime.shared.install() + await refresh() + return true + } catch { + if !Task.isCancelled { self.error = Self.message(error) } + await refresh() + return false + } + } + installation = task + let succeeded = await task.value + installation = nil + isInstalling = false + return succeeded + } + + func cancel() { installation?.cancel() } + + private static func message(_ error: Error) -> String { + error.localizedDescription.replacingOccurrences(of: "Embedded Git", with: "Embedded Git LFS") + .replacingOccurrences(of: "System Git is", with: "System Git LFS is") + } +} diff --git a/macgit/ViewModels/RepositoryLFSController.swift b/macgit/ViewModels/RepositoryLFSController.swift new file mode 100644 index 00000000..24409443 --- /dev/null +++ b/macgit/ViewModels/RepositoryLFSController.swift @@ -0,0 +1,88 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later +import Foundation +import Observation + +@MainActor @Observable +final class RepositoryLFSController { + let repository: URL + var snapshot: GitLFSSnapshot? + var isLoading = false + var operationLabel: String? + var transferProgress: GitLFSTransferProgress? + var error: String? + var notice: String? + var remote = "" + var review: GitLFSTrackingReview? + var candidates: [GitLFSCandidate] = [] + var isScanning = false + var conversion: GitLFSConversionReview? + var showingRuntimePrompt = false + private var operation: Task? + private var generation = 0 + + init(repository: URL) { self.repository = repository } + + func load(promptForRuntime: Bool = false) async { + guard operation == nil else { return } + generation += 1 + let generation = generation + isLoading = true + defer { if generation == self.generation { isLoading = false } } + await GitLFSRuntimeController.shared.refresh() + guard GitLFSRuntimeController.shared.status?.activeRuntime != nil else { + if promptForRuntime { showingRuntimePrompt = true } + return + } + do { + let snapshot = try await GitStatusService.shared.lfsSnapshot(in: repository) + guard generation == self.generation, !Task.isCancelled else { return } + self.snapshot = snapshot + if !snapshot.remotes.contains(remote) { remote = snapshot.suggestedRemote ?? "" } + } catch { if !Task.isCancelled { self.error = error.localizedDescription } } + } + + func prepareRule(pattern: String, literal: Bool, removing: Bool) async { + do { + review = try await GitStatusService.shared.reviewLFSTracking(pattern: pattern, literal: literal, removing: removing, in: repository) + } catch { self.error = error.localizedDescription } + } + + func perform(_ label: String, refresh: @escaping @MainActor () async -> Void, + action: @escaping @Sendable () async throws -> Void) { + guard operation == nil else { return } + operationLabel = label + transferProgress = nil + error = nil + notice = nil + operation = Task { + do { try await action(); notice = "Completed. Review any changes in File Status before committing." } + catch { self.error = Task.isCancelled ? "Operation cancelled. Repository state has been refreshed; completed changes were retained." : error.localizedDescription } + operation = nil + operationLabel = nil + transferProgress = nil + // Refresh even when the operation partially succeeded or was cancelled. + let refreshTask = Task { @MainActor in + await refresh() + await self.load() + } + await refreshTask.value + } + } + + func scanLargeFiles(minimumMiB: Int) async { + guard !isScanning else { return } + isScanning = true + defer { isScanning = false } + do { + candidates = try await GitStatusService.shared.largeLFSCandidates(minimumBytes: Int64(minimumMiB) * 1_048_576, in: repository) + if candidates.isEmpty { notice = "No untracked-by-LFS files above this threshold were found. Ignored files are excluded." } + } catch { self.error = error.localizedDescription } + } + + func prepareConversion(path: String) async { + do { conversion = try await GitStatusService.shared.reviewLFSConversion(path: path, in: repository) } + catch { self.error = error.localizedDescription } + } + + func cancel() { operation?.cancel() } +} diff --git a/macgit/Views/Common/GitSettingsView.swift b/macgit/Views/Common/GitSettingsView.swift index 69d4aec6..436b3bd0 100644 --- a/macgit/Views/Common/GitSettingsView.swift +++ b/macgit/Views/Common/GitSettingsView.swift @@ -25,6 +25,7 @@ struct GitSettingsView: View { Form { GitRuntimeSettingsSection(viewModel: viewModel) + GitLFSRuntimeSection() Section { TextField("Full Name", text: $viewModel.settings.userName) diff --git a/macgit/Views/FileStatus/FileStatusView.swift b/macgit/Views/FileStatus/FileStatusView.swift index 86432a3c..b196d2a9 100644 --- a/macgit/Views/FileStatus/FileStatusView.swift +++ b/macgit/Views/FileStatus/FileStatusView.swift @@ -41,12 +41,14 @@ struct FileStatusView: View { var onRequestUpdateCurrentBranch: (CurrentBranchIntegrationStatus) -> Void = { _ in } var onRequestApplyStash: (String) -> Void = { _ in } var onRequestComparePath: (ComparisonPath) -> Void = { _ in } + var onRequestTrackLFS: (String) -> Void = { _ in } var onAuthorizeCommit: () async -> Bool = { true } var onRequestPushAfterCommit: (String, String) async throws -> Void var onRunRepositoryOperation: RepositoryOperationRunner @ObservedObject private var integrationSettings = IntegrationSettingsStore.shared @State private var gitStatus: GitStatus = GitStatus(staged: [], unstaged: [], untracked: []) + @State private var lfsPaths = Set() @State private var changedFiles: [StatusFile] = [] @State private var visibleStagedFileCount = 100 @State private var visibleChangedFileCount = 100 @@ -486,6 +488,9 @@ struct FileStatusView: View { HStack(spacing: 0) { VStack(alignment: .leading, spacing: 1) { + if lfsPaths.contains(file.path) { + Text("LFS").font(.caption).foregroundStyle(.secondary).accessibilityLabel("Git LFS file") + } Text(file.displayName) .font(.system(size: 13, weight: .medium)) .lineLimit(1) @@ -713,6 +718,8 @@ struct FileStatusView: View { @ViewBuilder private func fileContextMenu(file: StatusFile, isStaged: Bool) -> some View { + Button("Track with Git LFS…", systemImage: "externaldrive") { onRequestTrackLFS(file.path) } + Divider() let selection = actionSelection comparisonMenu(file: file) @@ -1460,6 +1467,8 @@ struct FileStatusView: View { loadedIntegrationStatus = nil } + let paths = Set((loadedStatus.staged + loadedStatus.unstaged + loadedStatus.untracked).map(\.path)) + lfsPaths = (try? await GitStatusService.shared.lfsPaths(Array(paths), in: repositoryURL)) ?? [] gitStatus = loadedStatus changedFiles = loadedStatus.unstaged + loadedStatus.untracked currentBranch = loadedCurrentBranch diff --git a/macgit/Views/History/RevisionBrowserView.swift b/macgit/Views/History/RevisionBrowserView.swift index ea143135..7979f2aa 100644 --- a/macgit/Views/History/RevisionBrowserView.swift +++ b/macgit/Views/History/RevisionBrowserView.swift @@ -3,6 +3,8 @@ import SwiftUI struct RevisionBrowserView: View { let controller: RevisionBrowserController + @State private var lfsRemotes: [String] = [] + @State private var lfsRemote = "" let onCompare: (ComparisonPath, String) -> Void var body: some View { @@ -38,7 +40,11 @@ struct RevisionBrowserView: View { ) } } - .task { controller.load() } + .task { + controller.load() + lfsRemotes = await GitStatusService.shared.remotes(in: controller.repositoryURL) + if lfsRemotes.count == 1 { lfsRemote = lfsRemotes[0] } + } } private var tree: some View { @@ -124,6 +130,16 @@ struct RevisionBrowserView: View { .pointingHandCursor() } }.padding(12) + if controller.preview?.lfsPointer != nil { + HStack { + Picker("Download remote", selection: $lfsRemote) { + Text("Choose remote").tag("") + ForEach(lfsRemotes, id: \.self) { Text($0).tag($0) } + } + GitLFSPreviewDownloadButton(controller: controller, remote: lfsRemote) + .id(entry.id) + }.padding(12) + } Divider() if controller.isLoadingPreview { ProgressView("Loading file…").frame(maxWidth: .infinity, maxHeight: .infinity) diff --git a/macgit/Views/LFS/GitLFSCredentialEnvironment.swift b/macgit/Views/LFS/GitLFSCredentialEnvironment.swift new file mode 100644 index 00000000..e4b728ea --- /dev/null +++ b/macgit/Views/LFS/GitLFSCredentialEnvironment.swift @@ -0,0 +1,6 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later +import SwiftUI + +extension EnvironmentValues { + @Entry var gitLFSCredentialResolver: GitProviderCredentialResolver? = nil +} diff --git a/macgit/Views/LFS/GitLFSDownloadControls.swift b/macgit/Views/LFS/GitLFSDownloadControls.swift new file mode 100644 index 00000000..60ef0ae1 --- /dev/null +++ b/macgit/Views/LFS/GitLFSDownloadControls.swift @@ -0,0 +1,23 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later +import SwiftUI + +struct GitLFSDownloadControls: View { + @Bindable var runtime: GitLFSRuntimeController + var onInstalled: () -> Void = {} + + var body: some View { + if runtime.isInstalling { + HStack { + ProgressView().controlSize(.small) + Text("Downloading and verifying Git LFS…") + Button("Cancel", role: .cancel) { runtime.cancel() } + } + } else { + Button("Download & Use Embedded · \(runtime.downloadDescription)", systemImage: "arrow.down.circle") { + Task { + if await runtime.install() { onInstalled() } + } + } + } + } +} diff --git a/macgit/Views/LFS/GitLFSPreviewDownloadButton.swift b/macgit/Views/LFS/GitLFSPreviewDownloadButton.swift new file mode 100644 index 00000000..03904472 --- /dev/null +++ b/macgit/Views/LFS/GitLFSPreviewDownloadButton.swift @@ -0,0 +1,36 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later +import SwiftUI + +struct GitLFSPreviewDownloadButton: View { + let controller: RevisionBrowserController + let remote: String + @State private var runtime = GitLFSRuntimeController.shared + @State private var showDownload = false + + var body: some View { + Button("Download for Preview") { + Task { + await runtime.refresh() + if runtime.status?.activeRuntime == nil { showDownload = true } + else { controller.downloadLFSPreview(remote: remote) } + } + } + .disabled(remote.isEmpty || controller.isLoadingPreview || runtime.isInstalling) + .alert("Download Git LFS?", isPresented: $showDownload) { + Button("Download & Continue") { + let selectedID = controller.selectedEntry?.id + Task { + let installed = await runtime.install() + if installed, controller.selectedEntry?.id == selectedID { + controller.downloadLFSPreview(remote: remote) + } + } + } + Button("Cancel", role: .cancel) {} + } message: { + Text("Install a private copy managed by Commit+ on this Mac.\n\(runtime.downloadDescription)") + } + if runtime.isInstalling { ProgressView("Installing Git LFS…").controlSize(.small) } + if let error = runtime.error { Text(error).foregroundStyle(.red) } + } +} diff --git a/macgit/Views/LFS/GitLFSRuntimeSection.swift b/macgit/Views/LFS/GitLFSRuntimeSection.swift new file mode 100644 index 00000000..204655e8 --- /dev/null +++ b/macgit/Views/LFS/GitLFSRuntimeSection.swift @@ -0,0 +1,39 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later +import SwiftUI + +struct GitLFSRuntimeSection: View { + @State private var runtime = GitLFSRuntimeController.shared + + var body: some View { + Section { + HStack { + Text("Git LFS Runtime") + Spacer() + ForEach(GitRuntimePreference.allCases) { preference in + Button(preference == .automatic ? "Automatic" : preference == .system ? "System" : "Embedded") { + Task { await runtime.select(preference) } + } + .tint(runtime.status?.preference == preference ? .accentColor : .secondary) + .disabled(runtime.isInstalling || (preference == .embedded && runtime.status?.embeddedRuntime == nil)) + } + } + if let active = runtime.status?.activeRuntime { + LabeledContent("Active Git LFS", value: active.version) + Text(active.executableURL.path).font(.caption).foregroundStyle(.secondary).textSelection(.enabled) + } else { + Label("Git LFS is not available", systemImage: "arrow.down.circle") + } + if runtime.status?.embeddedRuntime == nil { + GitLFSDownloadControls(runtime: runtime) + } + if let error = runtime.error { Text(error).foregroundStyle(.red).textSelection(.enabled) } + Button("Refresh Git LFS Information") { Task { await runtime.refresh() } } + .disabled(runtime.isInstalling) + } header: { + Label("Git LFS Installation", systemImage: "externaldrive") + } footer: { + Text("Automatic uses System Git LFS when available, otherwise Embedded Git LFS. This choice is independent of Git Runtime and is stored only on this Mac.") + } + .task { await runtime.refresh() } + } +} diff --git a/macgit/Views/LFS/GitLFSView.swift b/macgit/Views/LFS/GitLFSView.swift new file mode 100644 index 00000000..1622d79c --- /dev/null +++ b/macgit/Views/LFS/GitLFSView.swift @@ -0,0 +1,301 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later +import AppKit +import SwiftUI + +struct GitLFSView: View { + let repositoryURL: URL + let credentialResolver: (String) async -> GitProviderCredentialResolver? + let refreshRepository: @MainActor @Sendable () async -> Void + @State private var controller: RepositoryLFSController + @State private var runtime = GitLFSRuntimeController.shared + @State private var tab = "Files" + @State private var search = "" + @State private var sortOrder = [KeyPathComparator(\GitLFSFile.name)] + @State private var stateFilter = "All states" + @State private var selection = Set() + @State private var pattern = "" + @State private var literal = false + @State private var minimumMiB = 50 + @State private var showingSetup = false + + init(repositoryURL: URL, initialPath: String? = nil, credentialResolver: @escaping (String) async -> GitProviderCredentialResolver?, + refreshRepository: @escaping @MainActor @Sendable () async -> Void) { + self.repositoryURL = repositoryURL + self.credentialResolver = credentialResolver + self.refreshRepository = refreshRepository + _pattern = State(initialValue: initialPath ?? "") + _literal = State(initialValue: initialPath != nil) + _tab = State(initialValue: initialPath == nil ? "Files" : "Tracking Rules") + _controller = State(initialValue: RepositoryLFSController(repository: repositoryURL)) + } + + private var files: [GitLFSFile] { + (controller.snapshot?.files ?? []).filter { search.isEmpty || $0.name.localizedCaseInsensitiveContains(search) } + .filter { stateFilter == "All states" || $0.localState == stateFilter } + .sorted(using: sortOrder) + } + + var body: some View { + @Bindable var controller = controller + VStack(alignment: .leading, spacing: 12) { + header + if let error = controller.error ?? runtime.error { + Label(error, systemImage: "exclamationmark.triangle").foregroundStyle(.red).textSelection(.enabled) + } + if let notice = controller.notice { Text(notice).foregroundStyle(.secondary) } + if runtime.status?.activeRuntime == nil { + ContentUnavailableView { + Label("Git LFS Is Required", systemImage: "externaldrive.badge.exclamationmark") + } description: { + Text("Download a private copy managed by Commit+, or choose System Git LFS in Settings.") + } actions: { + GitLFSDownloadControls(runtime: runtime) { Task { await controller.load() } } + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center) + } else if let snapshot = controller.snapshot { + if let issue = snapshot.setupIssue { + HStack { + Label(issue, systemImage: "wrench.and.screwdriver") + Spacer() + Button("Set Up Git LFS…") { showingSetup = true } + } + } + Picker("Git LFS view", selection: $tab) { + Text("Files").tag("Files") + Text("Tracking Rules").tag("Tracking Rules") + }.pickerStyle(.segmented) + if tab == "Files" { fileTable } else { rules(snapshot.rules) } + } else if controller.isLoading { + ProgressView("Reading Git LFS…").frame(maxWidth: .infinity, maxHeight: .infinity) + } else { + ContentUnavailableView("Unable to Read Git LFS", systemImage: "exclamationmark.triangle", description: Text("Check the message above, then Refresh to try again.")) + } + if let label = controller.operationLabel { + HStack { + ProgressView().controlSize(.small) + VStack(alignment: .leading) { + Text(label) + if let progress = controller.transferProgress { + ProgressView(value: Double(progress.bytes), total: Double(progress.totalBytes)) + Text("File \(progress.fileIndex) of \(progress.fileCount) · \(ByteCountFormatter.string(fromByteCount: progress.bytes, countStyle: .file)) / \(ByteCountFormatter.string(fromByteCount: progress.totalBytes, countStyle: .file)) · \(progress.name)") + .font(.caption).lineLimit(1).truncationMode(.middle) + } + } + Spacer() + Button("Cancel", role: .cancel) { controller.cancel() } + } + } + } + .padding() + .task { await controller.load(promptForRuntime: true) } + .onReceive(NotificationCenter.default.publisher(for: .repositoryLocalStateDidRefresh)) { notification in + guard let url = notification.object as? URL, url.standardizedFileURL == repositoryURL.standardizedFileURL else { return } + Task { await controller.load() } + } + .alert("Download Git LFS?", isPresented: $controller.showingRuntimePrompt) { + Button("Download & Continue") { + Task { await runtime.install(); await controller.load() } + } + Button("Cancel", role: .cancel) {} + } message: { + Text("Commit+ needs Git LFS to manage large files. Download a private copy on this Mac.\n\n\(runtime.downloadDescription)") + } + .confirmationDialog("Set up Git LFS in this repository?", isPresented: $showingSetup, titleVisibility: .visible) { + Button("Set Up Git LFS") { + let repository = repositoryURL + controller.perform("Setting up Git LFS…", refresh: refreshRepository) { + try await GitStatusService.shared.setupLFS(in: repository) + } + } + } message: { + Text("Adds local LFS filters and the pre-push hook. Existing custom hooks are preserved. No files are staged or committed.") + } + .sheet(item: $controller.conversion) { review in + VStack(alignment: .leading, spacing: 16) { + Text("Convert to Git LFS").font(.title2) + Text(review.path).textSelection(.enabled) + Text("Stage this file as an LFS pointer for your next commit. The working file stays intact. Existing commits and their storage size will not change. Review and stage the tracking rule in File Status as well.") + HStack { + Button("Cancel", role: .cancel) { controller.conversion = nil } + Spacer() + Button("Convert & Stage") { + controller.conversion = nil + let repository = repositoryURL + controller.perform("Converting selected file…", refresh: refreshRepository) { + try await GitStatusService.shared.convertLFS(review, in: repository) + } + }.buttonStyle(.borderedProminent) + } + }.padding(24).frame(width: 500) + } + .sheet(item: $controller.review) { review in + VStack(alignment: .leading, spacing: 16) { + Text(review.removing ? "Remove Tracking Rule" : "Review Tracking Rule").font(.title2) + Text(review.pattern).font(.headline).textSelection(.enabled) + ScrollView { Text(review.preview).textSelection(.enabled).frame(maxWidth: .infinity, alignment: .leading) } + Text("Only .gitattributes will change. Review and stage it in File Status. Existing files and commit history are not converted automatically.").foregroundStyle(.secondary) + HStack { + Button("Cancel", role: .cancel) { controller.review = nil } + Spacer() + Button("Apply Rule") { + controller.review = nil + let repository = repositoryURL + controller.perform("Updating tracking rule…", refresh: refreshRepository) { + try await GitStatusService.shared.applyLFSTracking(review, in: repository) + } + }.buttonStyle(.borderedProminent) + } + }.padding(24).frame(width: 540, height: 360) + } + } + + private var header: some View { + HStack { + VStack(alignment: .leading) { + Label("Git LFS", systemImage: "externaldrive").font(.title2) + Text("Current branch: \(controller.snapshot?.branch ?? "—") · Remote availability is checked when downloading.") + .font(.caption).foregroundStyle(.secondary) + } + Spacer() + Button("Refresh", systemImage: "arrow.clockwise") { Task { await controller.load() } } + .disabled(controller.isLoading || controller.operationLabel != nil) + } + } + + private var fileTable: some View { + @Bindable var controller = controller + return VStack(alignment: .leading) { + HStack { + TextField("Search files", text: $search) + Picker("State", selection: $stateFilter) { + ForEach(["All states", "Available", "Not downloaded", "Modified", "Conflict"], id: \.self) { Text($0).tag($0) } + }.frame(maxWidth: 150) + } + HStack { + Picker("Remote", selection: $controller.remote) { + Text("Choose remote").tag("") + ForEach(controller.snapshot?.remotes ?? [], id: \.self) { Text($0).tag($0) } + }.frame(maxWidth: 240) + Button("Download Missing", systemImage: "arrow.down.circle") { download() } + .disabled(controller.remote.isEmpty || controller.operationLabel != nil) + Menu("More", systemImage: "ellipsis.circle") { + Button("Download Selected") { download(paths: Array(selection)) } + .disabled(selection.isEmpty || controller.remote.isEmpty) + Button("Restore Cached Content") { + let repository = repositoryURL + controller.perform("Restoring cached content…", refresh: refreshRepository) { + try await GitStatusService.shared.restoreLFS(in: repository) + } + } + }.disabled(controller.operationLabel != nil) + } + Table(files, selection: $selection, sortOrder: $sortOrder) { + TableColumn("File", value: \.name) + TableColumn("Size", value: \.size) { file in Text(ByteCountFormatter.string(fromByteCount: file.size, countStyle: .file)) } + .width(min: 70, ideal: 90, max: 120) + TableColumn("Local Content", value: \.localState) + } + .overlay { + if files.isEmpty { + ContentUnavailableView("No LFS Files", systemImage: "externaldrive", description: Text("Add a tracking rule, then stage matching files in File Status.")) + } + } + if selection.count == 1, let file = files.first(where: { selection.contains($0.id) }) { + HStack { + Text("SHA-256: \(file.oid)").font(.caption).textSelection(.enabled) + Spacer() + Button("Reveal in Finder") { NSWorkspace.shared.activateFileViewerSelecting([repositoryURL.appendingPathComponent(file.name)]) } + } + } + Text("\(files.count) files · \(ByteCountFormatter.string(fromByteCount: files.reduce(0) { $0 + $1.size }, countStyle: .file)) referenced by this view. Local availability is not upload status.") + .font(.caption).foregroundStyle(.secondary) + } + } + + private func rules(_ rules: String) -> some View { + VStack(alignment: .leading, spacing: 12) { + Text("Track large assets by filename or pattern. Rules are shared through .gitattributes.") + TextField("Pattern, for example *.psd", text: $pattern) + .disabled(controller.operationLabel != nil || controller.snapshot?.setupIssue != nil) + HStack { + Toggle("Exact filename", isOn: $literal) + Button("Add…") { Task { await controller.prepareRule(pattern: pattern, literal: literal, removing: false) } } + Button("Remove…") { Task { await controller.prepareRule(pattern: pattern, literal: literal, removing: true) } } + }.disabled(controller.operationLabel != nil || controller.snapshot?.setupIssue != nil) + Table(GitLFSTrackingRule.displayRules(rules)) { + TableColumn("Pattern", value: \.pattern) + TableColumn("Source", value: \.source) + TableColumn("Action") { rule in + if rule.canRemove { + Button("Remove…") { Task { await controller.prepareRule(pattern: rule.pattern, literal: false, removing: true) } } + .disabled(controller.operationLabel != nil) + } else { + Text(rule.excluded ? "Excluded · edit source" : "Edit source file").foregroundStyle(.secondary) + } + } + } + DisclosureGroup("Raw tracking rules") { + ScrollView { Text(rules).font(.system(.caption, design: .monospaced)).textSelection(.enabled).frame(maxWidth: .infinity, alignment: .leading) } + .frame(maxHeight: 120) + } + HStack { + Stepper("Suggest files ≥ \(minimumMiB) MiB", value: $minimumMiB, in: 1...1024, step: 10) + Button("Find Large Files") { Task { await controller.scanLargeFiles(minimumMiB: minimumMiB) } } + .disabled(controller.isScanning) + if controller.isScanning { ProgressView().controlSize(.small) } + } + if !controller.candidates.isEmpty { + ScrollView { + LazyVStack(alignment: .leading) { + ForEach(controller.candidates) { candidate in + HStack { + Text(candidate.path).lineLimit(1).truncationMode(.middle) + Spacer() + Text(ByteCountFormatter.string(fromByteCount: candidate.size, countStyle: .file)) + Button("Track…") { + pattern = candidate.path + literal = true + Task { await controller.prepareRule(pattern: candidate.path, literal: true, removing: false) } + }.disabled(controller.snapshot?.setupIssue != nil) + } + } + } + }.frame(maxHeight: 150) + } + Button("Convert Existing File…") { + let panel = NSOpenPanel() + panel.directoryURL = repositoryURL + panel.canChooseDirectories = false + panel.allowsMultipleSelection = false + panel.begin { response in + guard response == .OK, let url = panel.url else { return } + let root = repositoryURL.standardizedFileURL.path + "/" + guard url.standardizedFileURL.path.hasPrefix(root) else { + controller.error = "Choose a file inside this repository." + return + } + let path = String(url.standardizedFileURL.path.dropFirst(root.count)) + Task { await controller.prepareConversion(path: path) } + } + }.disabled(controller.operationLabel != nil || controller.snapshot?.setupIssue != nil) + Button("Open Attributes File") { NSWorkspace.shared.open(repositoryURL.appendingPathComponent(".gitattributes")) } + Text("Changes here apply to root rules. Edit nested or inherited rules in their source attributes file. Removing a rule does not convert existing pointers or rewrite history.") + .font(.caption).foregroundStyle(.secondary) + } + } + + private func download(paths: [String]? = nil) { + let repository = repositoryURL + let remote = controller.remote + Task { + guard let resolver = await credentialResolver(remote) else { return } + controller.perform("Downloading LFS content for the current checkout…", refresh: refreshRepository) { + try await GitStatusService.shared.downloadLFS(remote: remote, in: repository, credentialResolver: resolver, paths: paths, onProgress: { progress in + Task { @MainActor in + if controller.operationLabel != nil { controller.transferProgress = progress } + } + }) + } + } + } +} diff --git a/macgit/Views/MainWindow/ContentView.swift b/macgit/Views/MainWindow/ContentView.swift index 1dc2515c..99aaed52 100644 --- a/macgit/Views/MainWindow/ContentView.swift +++ b/macgit/Views/MainWindow/ContentView.swift @@ -236,6 +236,7 @@ struct ContentView: View { ) // Prefer the scene that opened browser sign-in. If it was closed (or the // app relaunched), allow another existing scene to receive the callback. + .environment(\.gitLFSCredentialResolver, providerAccountController.credentialResolver()) .handlesExternalEvents( preferring: preferredExternalEvents, allowing: ["macgit://session", "macgit://open-repository"] diff --git a/macgit/Views/MainWindow/MainWindowView.swift b/macgit/Views/MainWindow/MainWindowView.swift index a7f54f32..92c831cf 100644 --- a/macgit/Views/MainWindow/MainWindowView.swift +++ b/macgit/Views/MainWindow/MainWindowView.swift @@ -141,6 +141,7 @@ struct MainWindowView: View { @State var pendingStashPaths: [String] = [] @State var pendingProviderAccountSelection: PendingGitProviderAccountSelection? @State var providerAccountSelectionContinuation: CheckedContinuation? + @State private var lfsTrackingPath: String? @StateObject var syncState = SyncState() @StateObject var undoManager = GitUndoManager() @StateObject var pullRequestController: PullRequestController @@ -1196,6 +1197,10 @@ struct MainWindowView: View { requestStashAction(ref: ref, action: .apply) }, onRequestComparePath: { pathComparisonWindow.show(path: $0, in: repositoryURL) }, + onRequestTrackLFS: { path in + lfsTrackingPath = path + selectedItem = .item(.gitLFS) + }, onAuthorizeCommit: authorizeProtectedBranchCommit, onRequestPushAfterCommit: pushAfterCommit, onRunRepositoryOperation: runRepositoryOperation @@ -1228,7 +1233,7 @@ struct MainWindowView: View { onRunRepositoryOperation: runRepositoryOperation, onRequestCheckout: checkoutRequest, onRequestExplainCommit: explainCommitWithRepositoryAI, - onRequestBrowseRevision: { revisionBrowserWindow.show(revision: $0.hash, in: repositoryURL) } + onRequestBrowseRevision: { revisionBrowserWindow.show(revision: $0.hash, in: repositoryURL, credentialResolver: providerCredentialResolver) } ) } case .branch, .worktree, .tag, .remoteBranch, .head: @@ -1240,7 +1245,7 @@ struct MainWindowView: View { onRunRepositoryOperation: runRepositoryOperation, onRequestCheckout: checkoutRequest, onRequestExplainCommit: explainCommitWithRepositoryAI, - onRequestBrowseRevision: { revisionBrowserWindow.show(revision: $0.hash, in: repositoryURL) } + onRequestBrowseRevision: { revisionBrowserWindow.show(revision: $0.hash, in: repositoryURL, credentialResolver: providerCredentialResolver) } ) case .item(.reflog): ReflogView( @@ -1310,6 +1315,13 @@ struct MainWindowView: View { EmptyStateView(message: "Select a subtree action from the sidebar") case .item(.search): SearchView(repositoryURL: repositoryURL) + case .item(.gitLFS): + GitLFSView(repositoryURL: repositoryURL, initialPath: lfsTrackingPath, credentialResolver: { remote in + await credentialResolverForRemoteOperation(remotes: [remote]) + }, refreshRepository: { + await syncState.refresh(repositoryURL: repositoryURL, force: true) + }) + .id(repositoryURL) case .item(.gitFlow): GitFlowDashboardView( configuration: gitFlowConfiguration, diff --git a/macgit/Views/MainWindow/RepoPickerView.swift b/macgit/Views/MainWindow/RepoPickerView.swift index b2bf046e..8dbea6b3 100644 --- a/macgit/Views/MainWindow/RepoPickerView.swift +++ b/macgit/Views/MainWindow/RepoPickerView.swift @@ -1081,6 +1081,7 @@ private struct RepoPickerCountBadge: View { } struct CloneSheetView: View { + @Environment(\.gitLFSCredentialResolver) private var lfsCredentialResolver @Environment(\.dismiss) private var dismiss @State private var remoteURL: String @State private var destinationPath = "" @@ -1091,6 +1092,9 @@ struct CloneSheetView: View { @State private var isLoadingRemoteBranches = false @State private var remoteBranchLoadError: String? @State private var recurseSubmodules = false + @State private var downloadLFSContent = true + @State private var clonedRepository: URL? + @State private var lfsRuntime = GitLFSRuntimeController.shared @State private var advancedOptionsExpanded = false @State private var showingDestinationPicker = false @State private var isCloning = false @@ -1211,6 +1215,8 @@ struct CloneSheetView: View { .frame(width: Self.trailingControlWidth, height: Self.controlHeight) } + Toggle("Download LFS content", isOn: $downloadLFSContent) + .padding(.leading, Self.labelWidth + 12) Toggle("Recurse submodules", isOn: $recurseSubmodules) .padding(.leading, Self.labelWidth + 12) } @@ -1219,8 +1225,13 @@ struct CloneSheetView: View { } HStack { - Label(repositoryStatusText, systemImage: "chevron.left.forwardslash.chevron.right") - .foregroundStyle(.secondary) + if lfsRuntime.isInstalling { + ProgressView("Downloading Git LFS…").controlSize(.small) + Button("Cancel Download", role: .cancel) { lfsRuntime.cancel() } + } else { + Label(repositoryStatusText, systemImage: "chevron.left.forwardslash.chevron.right") + .foregroundStyle(.secondary) + } Spacer() @@ -1248,9 +1259,17 @@ struct CloneSheetView: View { await loadRemoteBranches(for: trimmedRemoteURL) } .alert("Error", isPresented: $showingError, actions: { + if let clonedRepository { + if lfsRuntime.status?.activeRuntime == nil { + Button("Download Git LFS & Continue") { finishLFSClone(downloadRuntime: true) } + } else { + Button("Retry LFS Download") { finishLFSClone(downloadRuntime: false) } + } + Button("Open Cloned Repository") { onClone(clonedRepository); dismiss() } + } Button("OK", role: .cancel) {} }, message: { - Text(errorMessage ?? "An unknown error occurred") + Text((errorMessage ?? "An unknown error occurred") + (clonedRepository != nil && lfsRuntime.status?.activeRuntime == nil ? "\n\nDownload a private copy: " + lfsRuntime.downloadDescription : "")) }) .onChange(of: showingDestinationPicker) { _, newValue in if newValue { @@ -1394,7 +1413,9 @@ struct CloneSheetView: View { remoteURL: trimmedRemoteURL, to: finalURL, checkoutBranch: checkoutBranch, - recurseSubmodules: recurseSubmodules + recurseSubmodules: recurseSubmodules, + downloadLFSContent: downloadLFSContent, + credentialResolver: lfsCredentialResolver ) await MainActor.run { @@ -1402,7 +1423,9 @@ struct CloneSheetView: View { dismiss() } } catch { + await lfsRuntime.refresh() await MainActor.run { + clonedRepository = (error as? GitLFSCloneRecoveryError)?.repository errorMessage = error.localizedDescription showingError = true isCloning = false @@ -1411,6 +1434,28 @@ struct CloneSheetView: View { } } + private func finishLFSClone(downloadRuntime: Bool) { + guard let repository = clonedRepository else { return } + isCloning = true + Task { + let installed = downloadRuntime ? await lfsRuntime.install() : true + do { + guard installed else { throw GitError.commandFailed(lfsRuntime.error ?? "Git LFS installation was cancelled.") } + guard lfsRuntime.status?.activeRuntime != nil else { + throw GitError.commandFailed(lfsRuntime.error ?? "Git LFS installation was cancelled.") + } + try await GitStatusService.shared.setupLFS(in: repository) + try await GitStatusService.shared.downloadLFS(remote: "origin", in: repository, credentialResolver: lfsCredentialResolver) + onClone(repository) + dismiss() + } catch { + errorMessage = GitLFSErrorMessage.sanitized(error.localizedDescription) + showingError = true + isCloning = false + } + } + } + static func defaultRepositoryName(from remoteURL: String) -> String { var trimmed = remoteURL.trimmingCharacters(in: .whitespacesAndNewlines) trimmed = trimmed.trimmingCharacters(in: CharacterSet(charactersIn: "/")) diff --git a/macgit/Views/MainWindow/Sidebar/SidebarItem.swift b/macgit/Views/MainWindow/Sidebar/SidebarItem.swift index 2d09790f..9a8d7c48 100644 --- a/macgit/Views/MainWindow/Sidebar/SidebarItem.swift +++ b/macgit/Views/MainWindow/Sidebar/SidebarItem.swift @@ -22,6 +22,7 @@ enum SidebarItem: String, CaseIterable, Identifiable { case reflog = "Reflog" case pullRequests = "Pull Requests" case search = "Search" + case gitLFS = "Git LFS" case gitFlow = "Git Flow" var id: String { rawValue } @@ -33,6 +34,7 @@ enum SidebarItem: String, CaseIterable, Identifiable { case .reflog: return "list.bullet.rectangle" case .pullRequests: return "arrow.triangle.pull" case .search: return "magnifyingglass" + case .gitLFS: return "externaldrive" case .gitFlow: return "point.3.connected.trianglepath.dotted" } } diff --git a/macgit/Views/MainWindow/Sidebar/SidebarSection.swift b/macgit/Views/MainWindow/Sidebar/SidebarSection.swift index 1b19f331..7d24f4e7 100644 --- a/macgit/Views/MainWindow/Sidebar/SidebarSection.swift +++ b/macgit/Views/MainWindow/Sidebar/SidebarSection.swift @@ -78,7 +78,7 @@ enum SidebarSection: String, CaseIterable { var items: [SidebarItem] { switch self { case .workspace: - return [.fileStatus, .history, .reflog, .pullRequests, .search] + return [.fileStatus, .history, .reflog, .pullRequests, .gitLFS, .search] default: return [] } diff --git a/macgitTests/GitLFSIntegrationTests.swift b/macgitTests/GitLFSIntegrationTests.swift new file mode 100644 index 00000000..5c06d070 --- /dev/null +++ b/macgitTests/GitLFSIntegrationTests.swift @@ -0,0 +1,139 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later +import XCTest +@testable import macgit + +@MainActor +final class GitLFSIntegrationTests: XCTestCase { + func testCancellationStopsChildrenBeforeReturning() async throws { + let (repo, service) = try await fixture() + let resultFile = repo.appendingPathComponent("should-not-be-written") + let task = Task { + try await service.runProcessRaw(executableURL: URL(fileURLWithPath: "/bin/sh"), + arguments: ["-c", "sleep 2; echo unexpected > \"$RESULT_FILE\""], in: repo, + environment: ["PATH": "/usr/bin:/bin", "RESULT_FILE": resultFile.path]) + } + try await Task.sleep(for: .milliseconds(200)) + task.cancel() + do { _ = try await task.value; XCTFail("Expected cancellation") } + catch { XCTAssertTrue(error is CancellationError) } + try await Task.sleep(for: .seconds(2.2)) + XCTAssertFalse(FileManager.default.fileExists(atPath: resultFile.path)) + } + + func testSelectedLFSOverridesGitBundledExecutable() async throws { + let (repo, service) = try await fixture() + let core = repo.deletingLastPathComponent().appendingPathComponent("competing-core") + try FileManager.default.createDirectory(at: core, withIntermediateDirectories: true) + let competing = core.appendingPathComponent("git-lfs") + try "#!/bin/sh\necho WRONG-LFS\n".write(to: competing, atomically: true, encoding: .utf8) + try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: competing.path) + let environment = try await service.lfsRuntime.environment(inheriting: ["GIT_EXEC_PATH": core.path, "PATH": "/usr/bin:/bin"]) + let output = try await service.runProcessRaw(executableURL: URL(fileURLWithPath: "/usr/bin/git"), + arguments: ["lfs", "version"], in: repo, environment: environment) + XCTAssertTrue(String(decoding: output, as: UTF8.self).hasPrefix("git-lfs/")) + XCTAssertFalse(String(decoding: output, as: UTF8.self).contains("WRONG-LFS")) + } + + func testPushCloneAndDownloadRoundTripThroughService() async throws { + let (repo, service) = try await fixture() + try await service.setupLFS(in: repo) + let rule = try await service.reviewLFSTracking(pattern: "*.dat", literal: false, removing: false, in: repo) + try await service.applyLFSTracking(rule, in: repo) + let content = Data(repeating: 91, count: 8192) + try content.write(to: repo.appendingPathComponent("asset.dat")) + _ = try await service.runGit(arguments: ["add", "."], in: repo) + try await service.commit(message: "LFS asset", in: repo) + let remote = repo.deletingLastPathComponent().appendingPathComponent("remote.git") + _ = try await service.runGit(arguments: ["init", "--bare", remote.path], in: repo) + _ = try await service.runGit(arguments: ["remote", "add", "origin", remote.path], in: repo) + _ = try await service.runGit(arguments: ["push", "-u", "origin", "main"], in: repo) + _ = try await service.runGit(arguments: ["symbolic-ref", "HEAD", "refs/heads/main"], in: remote) + let clone = repo.deletingLastPathComponent().appendingPathComponent("clone") + try await service.cloneRepository(remoteURL: remote.path, to: clone, checkoutBranch: "main", recurseSubmodules: false, downloadLFSContent: false) + XCTAssertNotNil(GitLFSPointer(try String(contentsOf: clone.appendingPathComponent("asset.dat"), encoding: .utf8))) + try await service.setupLFS(in: clone) + try await service.downloadLFS(remote: "origin", in: clone, credentialResolver: nil) + XCTAssertEqual(try Data(contentsOf: clone.appendingPathComponent("asset.dat")), content) + let changed = Data("local edits".utf8) + try changed.write(to: clone.appendingPathComponent("asset.dat")) + try await service.restoreLFS(in: clone) + XCTAssertEqual(try Data(contentsOf: clone.appendingPathComponent("asset.dat")), changed) + } + + private func fixture() async throws -> (URL, GitStatusService) { + let candidate = ProcessInfo.processInfo.environment["COMMITPLUS_TEST_LFS"] ?? "/opt/homebrew/bin/git-lfs" + guard FileManager.default.isExecutableFile(atPath: candidate) else { throw XCTSkip("Set COMMITPLUS_TEST_LFS to a Git LFS executable for integration tests.") } + let root = FileManager.default.temporaryDirectory.appendingPathComponent("lfs-tests-\(UUID())") + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + addTeardownBlock { try? FileManager.default.removeItem(at: root) } + let repository = root.appendingPathComponent("repository") + try FileManager.default.createDirectory(at: repository, withIntermediateDirectories: true) + let defaults = try XCTUnwrap(UserDefaults(suiteName: "lfs-tests-\(UUID())")) + let configuration = GitRuntimeConfiguration(applicationSupportDirectory: root, + candidateSystemGitURLs: [URL(fileURLWithPath: candidate)], manifest: GitLFSRuntime.manifest, + preferenceDefaults: defaults, preferenceKey: "runtime", managedDirectoryName: "LFS", + executableRelativePath: "git-lfs-3.8.0/git-lfs", versionPrefix: "git-lfs/") + let runtime = GitLFSRuntime(manager: GitRuntimeManager(configuration: configuration), commandDirectory: root.appendingPathComponent(".test-command-paths")) + let service = GitStatusService(lfsRuntime: runtime) + _ = try await service.runGit(arguments: ["init", "-b", "main"], in: repository) + for (key, value) in [("user.name", "LFS Tests"), ("user.email", "test@example.invalid"), ("commit.gpgsign", "false")] { + _ = try await service.runGit(arguments: ["config", key, value], in: repository) + } + return (repository, service) + } + + func testConversionProducesPointerWithoutChangingWorkingContent() async throws { + let (repo, service) = try await fixture() + let file = repo.appendingPathComponent("space 日本語.dat") + let content = Data(repeating: 42, count: 4096) + try content.write(to: file) + _ = try await service.runGit(arguments: ["add", "."], in: repo) + _ = try await service.runGit(arguments: ["commit", "-m", "ordinary file"], in: repo) + try await service.setupLFS(in: repo) + let rule = try await service.reviewLFSTracking(pattern: "*.dat", literal: false, removing: false, in: repo) + try await service.applyLFSTracking(rule, in: repo) + let review = try await service.reviewLFSConversion(path: file.lastPathComponent, in: repo) + try await service.convertLFS(review, in: repo) + let staged = try await service.runGit(arguments: ["show", ":" + file.lastPathComponent], in: repo) + XCTAssertEqual(GitLFSPointer(staged)?.size, 4096) + XCTAssertEqual(try Data(contentsOf: file), content) + do { try await service.validateLFSCommitAttributes(in: repo); XCTFail("Unstaged attributes must be reported") } catch {} + _ = try await service.runGit(arguments: ["add", ".gitattributes"], in: repo) + try await service.commit(message: "LFS", in: repo) + let snapshot = try await service.lfsSnapshot(in: repo) + XCTAssertEqual(snapshot.files.count, 1) + XCTAssertNil(snapshot.setupIssue) + } + + func testRuleReviewRejectsConcurrentAttributesChange() async throws { + let (repo, service) = try await fixture() + let review = try await service.reviewLFSTracking(pattern: "*.dat", literal: false, removing: false, in: repo) + try "# another editor\n".write(to: repo.appendingPathComponent(".gitattributes"), atomically: true, encoding: .utf8) + do { try await service.applyLFSTracking(review, in: repo); XCTFail("Must revalidate attributes") } catch {} + XCTAssertEqual(try String(contentsOf: repo.appendingPathComponent(".gitattributes"), encoding: .utf8), "# another editor\n") + } + + func testSetupPreservesCustomHook() async throws { + let (repo, service) = try await fixture() + let hook = repo.appendingPathComponent(".git/hooks/pre-push") + let content = "#!/bin/sh\necho custom\n" + try content.write(to: hook, atomically: true, encoding: .utf8) + do { try await service.setupLFS(in: repo); XCTFail("Custom hook must be preserved") } catch {} + XCTAssertEqual(try String(contentsOf: hook, encoding: .utf8), content) + } + + func testConversionRejectsConcurrentFileEditAndPartialStaging() async throws { + let (repo, service) = try await fixture() + try await service.setupLFS(in: repo) + let rule = try await service.reviewLFSTracking(pattern: "*.dat", literal: false, removing: false, in: repo) + try await service.applyLFSTracking(rule, in: repo) + let file = repo.appendingPathComponent("file.dat") + try "first".write(to: file, atomically: true, encoding: .utf8) + let review = try await service.reviewLFSConversion(path: "file.dat", in: repo) + try "edited".write(to: file, atomically: true, encoding: .utf8) + do { try await service.convertLFS(review, in: repo); XCTFail("Must detect concurrent edits") } catch {} + _ = try await service.runGit(arguments: ["add", "file.dat"], in: repo) + try "working only".write(to: file, atomically: true, encoding: .utf8) + do { _ = try await service.reviewLFSConversion(path: "file.dat", in: repo); XCTFail("Must preserve partial staging") } catch {} + } +} diff --git a/macgitTests/GitLFSRuntimeTests.swift b/macgitTests/GitLFSRuntimeTests.swift new file mode 100644 index 00000000..e3cf73c9 --- /dev/null +++ b/macgitTests/GitLFSRuntimeTests.swift @@ -0,0 +1,42 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later +import XCTest +@testable import macgit + +@MainActor +final class GitLFSRuntimeTests: XCTestCase { + func testEmbeddedInstallVerifiesAndSelectsPrivateRuntime() async throws { + guard let source = ProcessInfo.processInfo.environment["COMMITPLUS_TEST_LFS_ARCHIVE"] else { + throw XCTSkip("Set COMMITPLUS_TEST_LFS_ARCHIVE to the official archive for this architecture.") + } + let root = FileManager.default.temporaryDirectory.appendingPathComponent("lfs-install-\(UUID())") + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + let defaults = try XCTUnwrap(UserDefaults(suiteName: "lfs-install-\(UUID())")) + let configuration = GitRuntimeConfiguration(applicationSupportDirectory: root, candidateSystemGitURLs: [], + manifest: GitLFSRuntime.manifest, preferenceDefaults: defaults, preferenceKey: "lfsPreference", + managedDirectoryName: "GitLFS", executableRelativePath: "git-lfs-3.8.0/git-lfs", versionPrefix: "git-lfs/") + let manager = GitRuntimeManager(configuration: configuration, processRunner: GitLFSVersionRunner(), + downloader: LFSFixtureDownloader(source: URL(fileURLWithPath: source)), extractor: GitLFSArchiveExtractor()) + let runtime = GitLFSRuntime(manager: manager, commandDirectory: root.appendingPathComponent("commands")) + let before = await runtime.status() + XCTAssertNil(before.activeRuntime) + try await runtime.install() + let status = await runtime.status() + XCTAssertEqual(status.preference, .embedded) + XCTAssertTrue(status.activeRuntime?.version.hasPrefix("git-lfs/3.8.0") == true) + XCTAssertTrue(status.activeRuntime?.executableURL.path.hasPrefix(root.path) == true) + XCTAssertNil(defaults.string(forKey: "gitRuntimePreference")) + do { try await runtime.select(.system); XCTFail("Missing system runtime must not silently fall back") } catch {} + let after = await runtime.status() + XCTAssertEqual(after.preference, .embedded) + } +} + +private struct LFSFixtureDownloader: GitRuntimeDownloading { + let source: URL + func download(from url: URL) async throws -> URL { + let copy = FileManager.default.temporaryDirectory.appendingPathComponent("lfs-archive-\(UUID()).zip") + try FileManager.default.copyItem(at: source, to: copy) + return copy + } +} diff --git a/macgitTests/GitLFSTests.swift b/macgitTests/GitLFSTests.swift new file mode 100644 index 00000000..488d69e5 --- /dev/null +++ b/macgitTests/GitLFSTests.swift @@ -0,0 +1,95 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later +import XCTest +@testable import macgit + +final class GitLFSTests: XCTestCase { + func testProgressParsesByteCountsAndNamesWithSpaces() { + let progress = GitLFSTransferProgress("download 2/3 512/1024 Assets/large file.dat") + XCTAssertEqual(progress?.fileIndex, 2) + XCTAssertEqual(progress?.bytes, 512) + XCTAssertEqual(progress?.name, "Assets/large file.dat") + XCTAssertNil(GitLFSTransferProgress("download 1/1 100/0 bad")) + XCTAssertNil(GitLFSTransferProgress("arbitrary error output")) + } + + func testTrackingRulePresentationPreservesSourceAndExclusions() { + let rules = GitLFSTrackingRule.displayRules("Listing tracked patterns\n *.psd (.gitattributes)\n art/*.dat (nested/.gitattributes)\nListing excluded patterns\n skip.dat (.gitattributes)\n") + XCTAssertEqual(rules.map(\.pattern), ["*.psd", "art/*.dat", "skip.dat"]) + XCTAssertTrue(rules[0].canRemove) + XCTAssertFalse(rules[1].canRemove) + XCTAssertTrue(rules[2].excluded) + XCTAssertFalse(rules[2].canRemove) + } + func testDiagnosticsRemoveURLCredentialsAndSignedQueries() { + let safe = GitLFSErrorMessage.sanitized("download https://user:secret@example.com/object?signature=private failed") + XCTAssertFalse(safe.contains("secret")) + XCTAssertFalse(safe.contains("private")) + XCTAssertTrue(safe.contains("example.com/object")) + } + func testIncludeFiltersRejectAmbiguousSelections() throws { + XCTAssertEqual(try GitStatusService.lfsIncludePaths(["Assets/a.dat", "日本語 space.dat"]), "/Assets/a.dat,/日本語 space.dat") + for path in ["a,b.dat", "*.dat", "a[1].dat", "../outside", "line\nbreak", " trailing "] { + XCTAssertThrowsError(try GitStatusService.lfsIncludePaths([path])) + } + } + + func testMinimumLFSVersion() { + XCTAssertTrue(GitLFSVersionRunner.isSupported("git-lfs/3.8.0 (GitHub; darwin arm64)")) + XCTAssertTrue(GitLFSVersionRunner.isSupported("git-lfs/4.0.0")) + XCTAssertFalse(GitLFSVersionRunner.isSupported("git-lfs/3.7.0")) + XCTAssertFalse(GitLFSVersionRunner.isSupported("git version 2.53.0")) + } + func testPointerRequiresValidHashSizeAndUniqueFields() { + let hash = String(repeating: "a", count: 64) + let valid = "version https://git-lfs.github.com/spec/v1\noid sha256:\(hash)\nsize 120\n" + XCTAssertEqual(GitLFSPointer(valid)?.size, 120) + XCTAssertEqual(GitLFSPointer(valid.replacingOccurrences(of: "\n", with: "\r\n"))?.oid, hash) + XCTAssertNil(GitLFSPointer(valid.replacingOccurrences(of: hash, with: "abc"))) + XCTAssertNil(GitLFSPointer(valid.replacingOccurrences(of: "size 120", with: "size -1"))) + XCTAssertNil(GitLFSPointer(valid + "size 1\n")) + XCTAssertNil(GitLFSPointer(valid + "unexpected data\n")) + } + + func testJSONPreservesUnusualFilenamesAndCacheDistinction() throws { + let data = Data(#"{"files":[{"name":"space\n日本語.dat","size":42,"oid":"abc","checkout":false,"downloaded":true}]}"#.utf8) + let file = try XCTUnwrap(JSONDecoder().decode(GitLFSFileList.self, from: data).files?.first) + XCTAssertEqual(file.name, "space\n日本語.dat") + XCTAssertEqual(file.localState, "Cached · Restore Content") + XCTAssertNil(try JSONDecoder().decode(GitLFSFileList.self, from: Data(#"{"files":null}"#.utf8)).files) + } + + func testArchiveRejectsTraversalAndAbsolutePaths() throws { + try GitLFSArchiveExtractor.validateListing("git-lfs-3.8.0/\ngit-lfs-3.8.0/git-lfs\n") + for path in ["/tmp/git-lfs", "git-lfs-3.8.0/../../outside", "other/git-lfs", ""] { + XCTAssertThrowsError(try GitLFSArchiveExtractor.validateListing(path)) + } + } + + func testScopedAskpassDoesNotReturnTokenToAnotherHost() throws { + let injection = try TemporaryGitCredentialInjector().injection(for: GitCredential(username: "tester", token: "test-only-token")) + defer { injection.cleanup() } + var environment = injection.environment + environment["MACGIT_GIT_CREDENTIAL_HOST"] = "git.example.com" + environment["MACGIT_GIT_CREDENTIAL_SCHEME"] = "https" + func ask(_ prompt: String) throws -> (Int32, String) { + let task = Process() + task.executableURL = URL(fileURLWithPath: try XCTUnwrap(environment["GIT_ASKPASS"])) + task.arguments = [prompt] + task.environment = environment + let output = Pipe() + task.standardOutput = output + try task.run() + let data = output.fileHandleForReading.readDataToEndOfFile() + task.waitUntilExit() + return (task.terminationStatus, String(decoding: data, as: UTF8.self)) + } + let allowed = try ask("Password for 'https://tester@git.example.com':") + XCTAssertEqual(allowed.0, 0) + XCTAssertEqual(allowed.1.trimmingCharacters(in: .newlines), "test-only-token") + for prompt in ["Password for 'https://evil.example.com':", "Password for 'https://git.example.com.evil.test':", "Password for 'http://git.example.com':"] { + let denied = try ask(prompt) + XCTAssertNotEqual(denied.0, 0) + XCTAssertEqual(denied.1, "") + } + } +} diff --git a/macgitTests/RevisionBrowserServiceTests.swift b/macgitTests/RevisionBrowserServiceTests.swift index 237624a6..b6fea2e8 100644 --- a/macgitTests/RevisionBrowserServiceTests.swift +++ b/macgitTests/RevisionBrowserServiceTests.swift @@ -62,7 +62,8 @@ final class RevisionBrowserServiceTests: XCTestCase { XCTAssertTrue(results["large"]?.message?.contains("2 MB") == true) XCTAssertNil(results["large"]?.text) XCTAssertEqual(results["empty"]?.text, "") - XCTAssertTrue(results["lfs"]?.message?.contains("LFS pointer") == true) + XCTAssertNil(results["lfs"]?.lfsPointer) + XCTAssertNil(results["lfs"]?.message) XCTAssertTrue(results["submodule"]?.message?.contains(sha) == true) // An incorrect caller-provided size must not bypass the process output bound. let large = try XCTUnwrap(entries.first { $0.path == "large" }) From 7fd3ac8bf512c981ac2159724180a3a4f94b326e Mon Sep 17 00:00:00 2001 From: Thanh Tran Date: Thu, 24 Sep 2026 17:33:30 +0700 Subject: [PATCH 2/8] feat: Add automatic hook integration for Git LFS setup Install a repository-local hook dispatcher that forwards to existing hooks, supports custom core.hooksPath and worktree config, and preserve pre-push input and failure status. Parse git status with NUL-delimited porcelain to keep filenames verbatim. --- docs/git-lfs.md | 2 +- macgit/Services/GitStatusService+LFS.swift | 82 +++++++++++++++++-- macgit/Services/GitStatusService+Status.swift | 22 +++-- macgit/Views/FileStatus/FileStatusView.swift | 12 +-- macgit/Views/FileStatus/GitLFSChip.swift | 19 +++++ macgit/Views/LFS/GitLFSView.swift | 43 ++++++++-- macgit/Views/MainWindow/MainWindowView.swift | 2 +- macgitTests/GitLFSIntegrationTests.swift | 70 +++++++++++++++- macgitTests/GitLFSTests.swift | 16 ++++ macgitTests/GitStatusServiceStatusTests.swift | 33 ++++++++ 10 files changed, 263 insertions(+), 38 deletions(-) create mode 100644 macgit/Views/FileStatus/GitLFSChip.swift diff --git a/docs/git-lfs.md b/docs/git-lfs.md index 4d34a848..681e54ea 100644 --- a/docs/git-lfs.md +++ b/docs/git-lfs.md @@ -10,7 +10,7 @@ Git LFS stores large file content separately from the small pointer committed to 4. Open **Tracking Rules**, enter a pattern such as `*.psd`, review it, and apply. For an individual filename, enable **Exact filename**. You can also start from a file's **Track with Git LFS…** context menu in File Status. 5. Review and stage `.gitattributes` and the affected files in File Status, then commit and push normally. -The setup action preserves custom hooks. Repositories with a custom `core.hooksPath` require manual integration using `git lfs install --local --manual`. Refresh after integrating the hook. Commit+ does not overwrite shared hooks. +The setup action automatically integrates existing hooks, including a custom `core.hooksPath`. Commit+ installs a repository-local hook directory that forwards to the original hooks and runs Git LFS. Original hook files remain unchanged. Pre-push hooks receive the same arguments and input as LFS; a failing original hook stops the push. With worktree-specific configuration enabled, setup applies to the selected worktree. ## Choose the runtime diff --git a/macgit/Services/GitStatusService+LFS.swift b/macgit/Services/GitStatusService+LFS.swift index ebf4fadf..a83c3131 100644 --- a/macgit/Services/GitStatusService+LFS.swift +++ b/macgit/Services/GitStatusService+LFS.swift @@ -100,8 +100,8 @@ extension GitStatusService { let contents = (try? String(contentsOf: hook, encoding: .utf8)) ?? "" let filter = (try? await runGit(arguments: ["config", "--get", "filter.lfs.process"], in: repository)) ?? "" guard filter.contains("git-lfs filter-process") else { return "Git LFS filters need setup in this repository." } - guard contents.contains("git lfs pre-push") || contents.contains("git-lfs pre-push") else { - return contents.isEmpty ? "The Git LFS pre-push hook is missing." : "An existing pre-push hook needs manual LFS integration. It will not be overwritten." + guard contents.contains("# Commit+ LFS hook dispatcher v1") || contents.contains("git lfs pre-push") || contents.contains("git-lfs pre-push") else { + return contents.isEmpty ? "The Git LFS pre-push hook is missing." : "Git LFS needs to be connected to the existing pre-push hook." } guard FileManager.default.isExecutableFile(atPath: hook.path) else { return "The pre-push hook is not executable." } return nil @@ -110,16 +110,80 @@ extension GitStatusService { func setupLFS(in repository: URL) async throws { let key = try await acquireLFSMutation(in: repository) defer { lfsMutations.remove(key) } - let configuredPath = (try? await runGit(arguments: ["config", "--get", "core.hooksPath"], in: repository)) ?? "" + let scope = (try? await runGit(arguments: ["config", "--bool", "extensions.worktreeConfig"], in: repository))? + .trimmingCharacters(in: .whitespacesAndNewlines) == "true" ? "--worktree" : "--local" let hook = try await lfsHookURL(in: repository) - if !configuredPath.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { - throw GitError.commandFailed("This repository uses core.hooksPath. Integrate Git LFS with that hook manually (git lfs install --local --manual), then Refresh. Commit+ will not modify shared or custom hooks.") - } let contents = (try? String(contentsOf: hook, encoding: .utf8)) ?? "" - if !contents.isEmpty && !contents.contains("git lfs pre-push") && !contents.contains("git-lfs pre-push") { - throw GitError.commandFailed("An existing pre-push hook needs manual integration. Run git lfs install --local --manual to see the required hook changes, then Refresh.") + // Repeated setup must not wrap our own dispatcher again. + if contents.contains("# Commit+ LFS hook dispatcher v1") { + _ = try await runLFS(["install", scope, "--skip-repo"], in: repository) + try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: hook.path) + return + } + let configuredPath = (try? await runGit(arguments: ["config", "--path", "--get", "core.hooksPath"], in: repository))? + .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + // Keep relative hooks paths relative to Git's working directory, including other worktrees. + let originalDirectory = configuredPath.isEmpty ? hook.deletingLastPathComponent().path : configuredPath + let commonPath = try await runGit(arguments: ["rev-parse", "--path-format=absolute", "--git-common-dir"], in: repository) + let directory = URL(fileURLWithPath: commonPath.trimmingCharacters(in: .whitespacesAndNewlines)) + .appendingPathComponent("commitplus-lfs-hooks/\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + do { + // Dispatch standard hooks even if absent today, so adding a hook to the original directory still works. + let names = Set([ + "applypatch-msg", "pre-applypatch", "post-applypatch", "pre-commit", "pre-merge-commit", + "prepare-commit-msg", "commit-msg", "post-commit", "pre-rebase", "post-checkout", "post-merge", + "pre-push", "pre-receive", "update", "proc-receive", "post-receive", "post-update", + "reference-transaction", "push-to-checkout", "pre-auto-gc", "post-rewrite", + "sendemail-validate", "fsmonitor-watchman", "p4-changelist", "p4-prepare-changelist", + "p4-post-changelist", "p4-pre-submit", "post-index-change" + ]) + for name in names { + let original = originalDirectory + "/" + name + let existingURL = URL(fileURLWithPath: original, relativeTo: repository) + let existing = (try? String(contentsOf: existingURL, encoding: .utf8)) ?? "" + let alreadyRunsLFS = FileManager.default.isExecutableFile(atPath: existingURL.path) + && (existing.contains("git lfs " + name) || existing.contains("git-lfs " + name)) + let lfsHook = ["pre-push", "post-checkout", "post-merge", "post-commit"].contains(name) && !alreadyRunsLFS + let script = Self.lfsHookDispatcher(original: original, name: name, includeLFS: lfsHook) + let destination = directory.appendingPathComponent(name) + try script.write(to: destination, atomically: true, encoding: .utf8) + try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: destination.path) + } + _ = try await runLFS(["install", scope, "--skip-repo"], in: repository) + // Publish only after the complete hook directory and filters are ready. + _ = try await runGit(arguments: ["config", scope, "core.hooksPath", directory.path], in: repository) + } catch { + try? FileManager.default.removeItem(at: directory) + throw error + } + } + + private static func lfsHookDispatcher(original: String, name: String, includeLFS: Bool) -> String { + let quoted = "'" + original.replacingOccurrences(of: "'", with: "'\\''") + "'" + let header = "#!/bin/sh\n# Commit+ LFS hook dispatcher v1\noriginal=" + quoted + "\n" + guard includeLFS else { + return header + "if [ -x \"$original\" ]; then exec \"$original\" \"$@\"; fi\nexit 0\n" + } + if name == "pre-push" { + // Both consumers require the complete ref-update stream. Preserve hook arguments and failure status. + return header + """ + input=$(mktemp "${TMPDIR:-/tmp}/commitplus-lfs.XXXXXXXX") || exit 1 + trap 'rm -f "$input"' EXIT + trap 'exit 1' HUP INT TERM + cat > "$input" || exit 1 + if [ -x "$original" ]; then + "$original" "$@" < "$input" || exit $? + fi + git lfs pre-push "$@" < "$input" + """ + "\n" } - _ = try await runLFS(["install", "--local"], in: repository) + return header + """ + if [ -x "$original" ]; then + "$original" "$@" || exit $? + fi + git lfs \(name) "$@" + """ + "\n" } func reviewLFSTracking(pattern: String, literal: Bool, removing: Bool, in repository: URL) async throws -> GitLFSTrackingReview { diff --git a/macgit/Services/GitStatusService+Status.swift b/macgit/Services/GitStatusService+Status.swift index f587d7b0..80e8845f 100644 --- a/macgit/Services/GitStatusService+Status.swift +++ b/macgit/Services/GitStatusService+Status.swift @@ -58,27 +58,25 @@ extension GitStatusService { } func status(for repositoryURL: URL) async throws -> GitStatus { - let output = try await runGit(arguments: ["status", "--porcelain", "--untracked-files=all"], in: repositoryURL) + let output = try await runGit(arguments: ["status", "--porcelain=v1", "-z", "--untracked-files=all"], in: repositoryURL) var staged: [StatusFile] = [] var unstaged: [StatusFile] = [] var untracked: [StatusFile] = [] - for line in output.split(separator: "\n") { - let line = String(line) + var records = output.split(separator: "\0").makeIterator() + while let record = records.next() { + let line = String(record) guard line.count >= 3 else { continue } let indexStatus = line.prefix(1) let worktreeStatus = line.dropFirst(1).prefix(1) let pathPart = String(line.dropFirst(3)) - // Parse renamed paths (R old -> new) - var path = pathPart - var originalPath: String? = nil - if indexStatus == "R" || worktreeStatus == "R" { - let components = pathPart.split(separator: " -> ", maxSplits: 1) - if components.count == 2 { - originalPath = String(components[0]) - path = String(components[1]) - } + // NUL-delimited porcelain keeps filenames verbatim. Renames/copies emit + // the destination first, followed by a separate source-path record. + let path = pathPart + var originalPath: String? + if indexStatus == "R" || worktreeStatus == "R" || indexStatus == "C" || worktreeStatus == "C" { + originalPath = records.next().map(String.init) } let indexChar = Character(String(indexStatus)) diff --git a/macgit/Views/FileStatus/FileStatusView.swift b/macgit/Views/FileStatus/FileStatusView.swift index b196d2a9..8bcdd524 100644 --- a/macgit/Views/FileStatus/FileStatusView.swift +++ b/macgit/Views/FileStatus/FileStatusView.swift @@ -488,12 +488,14 @@ struct FileStatusView: View { HStack(spacing: 0) { VStack(alignment: .leading, spacing: 1) { - if lfsPaths.contains(file.path) { - Text("LFS").font(.caption).foregroundStyle(.secondary).accessibilityLabel("Git LFS file") + HStack(spacing: 6) { + Text(file.displayName) + .font(.system(size: 13, weight: .medium)) + .lineLimit(1) + if lfsPaths.contains(file.path) { + GitLFSChip() + } } - Text(file.displayName) - .font(.system(size: 13, weight: .medium)) - .lineLimit(1) if let original = file.originalPath { Text("\(original) → \(file.path)") .font(.system(size: 10)) diff --git a/macgit/Views/FileStatus/GitLFSChip.swift b/macgit/Views/FileStatus/GitLFSChip.swift new file mode 100644 index 00000000..9c629ab9 --- /dev/null +++ b/macgit/Views/FileStatus/GitLFSChip.swift @@ -0,0 +1,19 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later +import SwiftUI + +struct GitLFSChip: View { + var body: some View { + Text("LFS") + .font(.caption.weight(.medium)) + .foregroundStyle(.secondary) + .padding(.horizontal, 5) + .padding(.vertical, 1) + .overlay { + RoundedRectangle(cornerRadius: 3) + .strokeBorder(.secondary.opacity(0.6), lineWidth: 1) + } + .fixedSize() + .accessibilityLabel("Tracked by Git LFS") + .help("Tracked by Git Large File Storage") + } +} diff --git a/macgit/Views/LFS/GitLFSView.swift b/macgit/Views/LFS/GitLFSView.swift index 1622d79c..655e022e 100644 --- a/macgit/Views/LFS/GitLFSView.swift +++ b/macgit/Views/LFS/GitLFSView.swift @@ -4,7 +4,7 @@ import SwiftUI struct GitLFSView: View { let repositoryURL: URL - let credentialResolver: (String) async -> GitProviderCredentialResolver? + let credentialResolver: @MainActor (String) async -> GitProviderCredentialResolver? let refreshRepository: @MainActor @Sendable () async -> Void @State private var controller: RepositoryLFSController @State private var runtime = GitLFSRuntimeController.shared @@ -18,7 +18,7 @@ struct GitLFSView: View { @State private var minimumMiB = 50 @State private var showingSetup = false - init(repositoryURL: URL, initialPath: String? = nil, credentialResolver: @escaping (String) async -> GitProviderCredentialResolver?, + init(repositoryURL: URL, initialPath: String? = nil, credentialResolver: @escaping @MainActor (String) async -> GitProviderCredentialResolver?, refreshRepository: @escaping @MainActor @Sendable () async -> Void) { self.repositoryURL = repositoryURL self.credentialResolver = credentialResolver @@ -176,6 +176,7 @@ struct GitLFSView: View { Text("Choose remote").tag("") ForEach(controller.snapshot?.remotes ?? [], id: \.self) { Text($0).tag($0) } }.frame(maxWidth: 240) + Spacer(minLength: 12) Button("Download Missing", systemImage: "arrow.down.circle") { download() } .disabled(controller.remote.isEmpty || controller.operationLabel != nil) Menu("More", systemImage: "ellipsis.circle") { @@ -219,6 +220,8 @@ struct GitLFSView: View { .disabled(controller.operationLabel != nil || controller.snapshot?.setupIssue != nil) HStack { Toggle("Exact filename", isOn: $literal) + .fixedSize() + Spacer(minLength: 12) Button("Add…") { Task { await controller.prepareRule(pattern: pattern, literal: literal, removing: false) } } Button("Remove…") { Task { await controller.prepareRule(pattern: pattern, literal: literal, removing: true) } } }.disabled(controller.operationLabel != nil || controller.snapshot?.setupIssue != nil) @@ -238,11 +241,17 @@ struct GitLFSView: View { ScrollView { Text(rules).font(.system(.caption, design: .monospaced)).textSelection(.enabled).frame(maxWidth: .infinity, alignment: .leading) } .frame(maxHeight: 120) } - HStack { - Stepper("Suggest files ≥ \(minimumMiB) MiB", value: $minimumMiB, in: 1...1024, step: 10) - Button("Find Large Files") { Task { await controller.scanLargeFiles(minimumMiB: minimumMiB) } } - .disabled(controller.isScanning) - if controller.isScanning { ProgressView().controlSize(.small) } + Divider() + ViewThatFits(in: .horizontal) { + HStack(spacing: 16) { + largeFileSearchControls + Spacer(minLength: 16) + trackingFileActions + } + VStack(alignment: .leading, spacing: 12) { + largeFileSearchControls + trackingFileActions.frame(maxWidth: .infinity, alignment: .trailing) + } } if !controller.candidates.isEmpty { ScrollView { @@ -262,6 +271,23 @@ struct GitLFSView: View { } }.frame(maxHeight: 150) } + Text("Changes here apply to root rules. Edit nested or inherited rules in their source attributes file. Removing a rule does not convert existing pointers or rewrite history.") + .font(.caption).foregroundStyle(.secondary) + } + } + + private var largeFileSearchControls: some View { + HStack { + Stepper("Suggest files ≥ \(minimumMiB) MiB", value: $minimumMiB, in: 1...1024, step: 10) + Button("Find Large Files") { Task { await controller.scanLargeFiles(minimumMiB: minimumMiB) } } + .disabled(controller.isScanning) + if controller.isScanning { ProgressView().controlSize(.small) } + } + .fixedSize(horizontal: true, vertical: false) + } + + private var trackingFileActions: some View { + HStack(spacing: 8) { Button("Convert Existing File…") { let panel = NSOpenPanel() panel.directoryURL = repositoryURL @@ -279,9 +305,8 @@ struct GitLFSView: View { } }.disabled(controller.operationLabel != nil || controller.snapshot?.setupIssue != nil) Button("Open Attributes File") { NSWorkspace.shared.open(repositoryURL.appendingPathComponent(".gitattributes")) } - Text("Changes here apply to root rules. Edit nested or inherited rules in their source attributes file. Removing a rule does not convert existing pointers or rewrite history.") - .font(.caption).foregroundStyle(.secondary) } + .fixedSize(horizontal: true, vertical: false) } private func download(paths: [String]? = nil) { diff --git a/macgit/Views/MainWindow/MainWindowView.swift b/macgit/Views/MainWindow/MainWindowView.swift index 92c831cf..10b654b4 100644 --- a/macgit/Views/MainWindow/MainWindowView.swift +++ b/macgit/Views/MainWindow/MainWindowView.swift @@ -1316,7 +1316,7 @@ struct MainWindowView: View { case .item(.search): SearchView(repositoryURL: repositoryURL) case .item(.gitLFS): - GitLFSView(repositoryURL: repositoryURL, initialPath: lfsTrackingPath, credentialResolver: { remote in + GitLFSView(repositoryURL: repositoryURL, initialPath: lfsTrackingPath, credentialResolver: { @MainActor remote in await credentialResolverForRemoteOperation(remotes: [remote]) }, refreshRepository: { await syncState.refresh(repositoryURL: repositoryURL, force: true) diff --git a/macgitTests/GitLFSIntegrationTests.swift b/macgitTests/GitLFSIntegrationTests.swift index 5c06d070..20e098a8 100644 --- a/macgitTests/GitLFSIntegrationTests.swift +++ b/macgitTests/GitLFSIntegrationTests.swift @@ -118,8 +118,76 @@ final class GitLFSIntegrationTests: XCTestCase { let hook = repo.appendingPathComponent(".git/hooks/pre-push") let content = "#!/bin/sh\necho custom\n" try content.write(to: hook, atomically: true, encoding: .utf8) - do { try await service.setupLFS(in: repo); XCTFail("Custom hook must be preserved") } catch {} + try await service.setupLFS(in: repo) + let issue = try await service.lfsSetupIssue(in: repo) + XCTAssertNil(issue) + XCTAssertEqual(try String(contentsOf: hook, encoding: .utf8), content) + } + + func testCustomHooksPathDispatchesInputAndPreservesFailures() async throws { + let (repo, service) = try await fixture() + let original = repo.appendingPathComponent("custom hooks' directory") + try FileManager.default.createDirectory(at: original, withIntermediateDirectories: true) + _ = try await service.runGit(arguments: ["config", "core.hooksPath", "custom hooks' directory"], in: repo) + let hook = original.appendingPathComponent("pre-push") + let script = "#!/bin/sh\nprintf '%s\\n' \"$@\" > \"$ARGUMENTS\"\ncat > \"$ORIGINAL_INPUT\"\nexit \"${HOOK_EXIT:-0}\"\n" + try script.write(to: hook, atomically: true, encoding: .utf8) + try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: hook.path) + try await service.setupLFS(in: repo) + let path = try await service.runGit(arguments: ["config", "--get", "core.hooksPath"], in: repo) + .trimmingCharacters(in: .whitespacesAndNewlines) + try await service.setupLFS(in: repo) + let repeatedPath = try await service.runGit(arguments: ["config", "--get", "core.hooksPath"], in: repo) + .trimmingCharacters(in: .whitespacesAndNewlines) + XCTAssertEqual(path, repeatedPath) + XCTAssertEqual(try String(contentsOf: hook, encoding: .utf8), script) + + let bin = repo.appendingPathComponent("fake-bin") + try FileManager.default.createDirectory(at: bin, withIntermediateDirectories: true) + let git = bin.appendingPathComponent("git") + try "#!/bin/sh\ncat > \"$LFS_INPUT\"\n".write(to: git, atomically: true, encoding: .utf8) + try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: git.path) + let originalInput = repo.appendingPathComponent("original-input") + let lfsInput = repo.appendingPathComponent("lfs-input") + let arguments = repo.appendingPathComponent("arguments") + var environment = ["PATH": bin.path + ":/usr/bin:/bin", "HOOK": path + "/pre-push", + "ORIGINAL_INPUT": originalInput.path, "LFS_INPUT": lfsInput.path, "ARGUMENTS": arguments.path] + let command = "printf 'refs/heads/main abc refs/heads/main def\\n' | \"$HOOK\" origin 'remote with spaces'" + _ = try await service.runProcessRaw(executableURL: URL(fileURLWithPath: "/bin/sh"), + arguments: ["-c", command], in: repo, environment: environment) + XCTAssertEqual(try Data(contentsOf: originalInput), try Data(contentsOf: lfsInput)) + XCTAssertEqual(try String(contentsOf: arguments, encoding: .utf8), "origin\nremote with spaces\n") + try FileManager.default.removeItem(at: lfsInput) + environment["HOOK_EXIT"] = "17" + do { + _ = try await service.runProcessRaw(executableURL: URL(fileURLWithPath: "/bin/sh"), + arguments: ["-c", command], in: repo, environment: environment) + XCTFail("The original hook must veto the push") + } catch {} + XCTAssertFalse(FileManager.default.fileExists(atPath: lfsInput.path)) + + // A hook added after setup is still discovered in the original directory. + let laterHook = original.appendingPathComponent("pre-commit") + try "#!/bin/sh\necho forwarded\n".write(to: laterHook, atomically: true, encoding: .utf8) + try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: laterHook.path) + let output = try await service.runProcessRaw(executableURL: URL(fileURLWithPath: path + "/pre-commit"), + arguments: [], in: repo) + XCTAssertEqual(String(decoding: output, as: UTF8.self), "forwarded\n") + } + + func testSharedHooksPathIsNotModified() async throws { + let (repo, service) = try await fixture() + let shared = repo.deletingLastPathComponent().appendingPathComponent("shared-hooks") + try FileManager.default.createDirectory(at: shared, withIntermediateDirectories: true) + let hook = shared.appendingPathComponent("pre-push") + let content = "#!/bin/sh\nexit 0\n" + try content.write(to: hook, atomically: true, encoding: .utf8) + _ = try await service.runGit(arguments: ["config", "core.hooksPath", shared.path], in: repo) + try await service.setupLFS(in: repo) + XCTAssertEqual(try FileManager.default.contentsOfDirectory(atPath: shared.path), ["pre-push"]) XCTAssertEqual(try String(contentsOf: hook, encoding: .utf8), content) + let issue = try await service.lfsSetupIssue(in: repo) + XCTAssertNil(issue) } func testConversionRejectsConcurrentFileEditAndPartialStaging() async throws { diff --git a/macgitTests/GitLFSTests.swift b/macgitTests/GitLFSTests.swift index 488d69e5..20de5f05 100644 --- a/macgitTests/GitLFSTests.swift +++ b/macgitTests/GitLFSTests.swift @@ -3,6 +3,22 @@ import XCTest @testable import macgit final class GitLFSTests: XCTestCase { + @MainActor + func testDownloadCredentialCallbackPreservesMainActorAcrossSuspension() async { + var requestedRemote: String? + let view = GitLFSView(repositoryURL: URL(fileURLWithPath: "/tmp/lfs-callback-test"), + credentialResolver: { @MainActor remote in + MainActor.assertIsolated() + await Task.yield() + MainActor.assertIsolated() + requestedRemote = remote + return nil + }, refreshRepository: {}) + let result = await view.credentialResolver("origin") + XCTAssertNil(result) + XCTAssertEqual(requestedRemote, "origin") + } + func testProgressParsesByteCountsAndNamesWithSpaces() { let progress = GitLFSTransferProgress("download 2/3 512/1024 Assets/large file.dat") XCTAssertEqual(progress?.fileIndex, 2) diff --git a/macgitTests/GitStatusServiceStatusTests.swift b/macgitTests/GitStatusServiceStatusTests.swift index 8eb0d9f4..3b3cd6ca 100644 --- a/macgitTests/GitStatusServiceStatusTests.swift +++ b/macgitTests/GitStatusServiceStatusTests.swift @@ -63,6 +63,39 @@ final class GitStatusServiceStatusTests: XCTestCase { XCTAssertTrue(status.untracked.contains { $0.path == "clip.mp4" }, "Untracked .mp4 file should appear in status") } + func testStatusPathsWithSpecialCharactersCanBeStaged() async throws { + let repoURL = try makeTempRepo() + defer { try? FileManager.default.removeItem(at: repoURL) } + let names = ["Screen Recording 2026-09-23.mov", "quote\"file.txt", "日本語.txt", "line\nbreak.txt", "tab\tfile.txt", "old -> new.txt", "back\\slash.txt"] + for name in names { + try "content".write(to: repoURL.appendingPathComponent(name), atomically: true, encoding: .utf8) + } + let service = GitStatusService.shared + let status = try await service.status(for: repoURL) + XCTAssertEqual(Set(status.untracked.map(\.path)), Set(names)) + for file in status.untracked { + try await service.stage(file: file, in: repoURL) + } + let staged = try await service.status(for: repoURL) + XCTAssertEqual(Set(staged.staged.map(\.path)), Set(names)) + XCTAssertTrue(staged.untracked.isEmpty) + } + + func testStatusPreservesBothRenamePathsWithSpecialCharacters() async throws { + let repoURL = try makeTempRepo() + defer { try? FileManager.default.removeItem(at: repoURL) } + let oldPath = "old -> name\"日本語.txt" + let newPath = "new name\nfinal.txt" + try "rename content".write(to: repoURL.appendingPathComponent(oldPath), atomically: true, encoding: .utf8) + try runGit(["add", "--", oldPath], in: repoURL) + try runGit(["commit", "-m", "Add rename fixture"], in: repoURL) + try runGit(["mv", "--", oldPath, newPath], in: repoURL) + let status = try await GitStatusService.shared.status(for: repoURL) + let renamed = try XCTUnwrap(status.staged.first { $0.status == .renamed }) + XCTAssertEqual(renamed.path, newPath) + XCTAssertEqual(renamed.originalPath, oldPath) + } + func testStatusHidesUntrackedEmbeddedGitRepository() async throws { let repoURL = try makeTempRepo() let nested = repoURL.appendingPathComponent("Package/cki-tool") From 830b934ab2b577cf92316129670c7c212c971262 Mon Sep 17 00:00:00 2001 From: Thanh Tran Date: Fri, 25 Sep 2026 21:24:39 +0700 Subject: [PATCH 3/8] fix: Avoid redundant LFS probes for metadata-only git commands Share in-flight Git runtime status lookups between concurrent callers. Cache resolved LFS command directories instead of recreating them. Append --no-patch to welcome dashboard log queries. --- macgit/Services/GitLFSRuntime.swift | 25 +++++- .../GitStatusService+WelcomeDashboard.swift | 2 +- macgit/Services/GitStatusService.swift | 55 ++++++++++-- macgitTests/GitLFSRuntimeTests.swift | 83 +++++++++++++++++++ macgitTests/GitLFSTests.swift | 23 +++++ 5 files changed, 174 insertions(+), 14 deletions(-) diff --git a/macgit/Services/GitLFSRuntime.swift b/macgit/Services/GitLFSRuntime.swift index 5bf4b7f1..f5a5c54c 100644 --- a/macgit/Services/GitLFSRuntime.swift +++ b/macgit/Services/GitLFSRuntime.swift @@ -8,6 +8,7 @@ actor GitLFSRuntime { private let commandDirectory: URL private var cachedURL: URL? private var didResolve = false + private var statusTask: Task? private var commandPaths: [String: URL] = [:] init(manager: GitRuntimeManager? = nil, commandDirectory: URL? = nil) { @@ -46,10 +47,18 @@ actor GitLFSRuntime { } func status() async -> GitRuntimeStatus { - let status = await manager.status() - cachedURL = status.activeRuntime?.executableURL - didResolve = true - return status + // Actors are reentrant across await: all startup Git commands must share + // the same probe instead of launching one version process per caller. + if let statusTask { return await statusTask.value } + let task = Task { + let status = await manager.status() + cachedURL = status.activeRuntime?.executableURL + didResolve = true + statusTask = nil + return status + } + statusTask = task + return await task.value } func executable() async throws -> URL { @@ -73,6 +82,12 @@ actor GitLFSRuntime { let digest = SHA256.hash(data: Data(key.utf8)).map { String(format: "%02x", $0) }.joined() let root = commandDirectory commands = root.appendingPathComponent(digest) + if FileManager.default.fileExists(atPath: commands.path) { + commandPaths[key] = commands + result["GIT_EXEC_PATH"] = commands.path + result["PATH"] = commands.path + ":" + (environment["PATH"] ?? "/usr/bin:/bin") + return result + } let staging = root.appendingPathComponent(UUID().uuidString) try FileManager.default.createDirectory(at: staging, withIntermediateDirectories: true, attributes: [.posixPermissions: 0o700]) defer { try? FileManager.default.removeItem(at: staging) } @@ -101,6 +116,8 @@ actor GitLFSRuntime { } func select(_ preference: GitRuntimePreference) async throws { + // Finish any startup probe before refreshing for the new preference. + if let statusTask { _ = await statusTask.value } try await manager.setPreference(preference) _ = await status() } diff --git a/macgit/Services/GitStatusService+WelcomeDashboard.swift b/macgit/Services/GitStatusService+WelcomeDashboard.swift index 187e55cb..b041153b 100644 --- a/macgit/Services/GitStatusService+WelcomeDashboard.swift +++ b/macgit/Services/GitStatusService+WelcomeDashboard.swift @@ -49,7 +49,7 @@ extension GitStatusService { do { let log = try await runGitBounded( arguments: ["log", "--all", "--ignore-missing", "HEAD", "--since-as-filter=@\(Int(firstDay.timeIntervalSince1970))", - "--no-show-signature", "--format=%H%x09%ae%x09%ct"], + "--no-show-signature", "--format=%H%x09%ae%x09%ct", "--no-patch"], in: repository.url, environment: environment, outputByteLimit: 2 * 1024 * 1024 ) diff --git a/macgit/Services/GitStatusService.swift b/macgit/Services/GitStatusService.swift index 681a9baa..c6ca2b81 100644 --- a/macgit/Services/GitStatusService.swift +++ b/macgit/Services/GitStatusService.swift @@ -246,7 +246,7 @@ actor GitStatusService { let lfsRuntime: GitLFSRuntime let branchListCache = BranchListCache() var lfsMutations = Set() - private var gitCorePaths: [String: String] = [:] + private var gitCorePaths: [String: Task] = [:] init( runner: (any GitCommandRunning)? = nil, @@ -262,7 +262,28 @@ actor GitStatusService { try await runtimeManager.executableURL().path } + /// Unknown commands retain LFS setup because they may invoke filters or hooks. + nonisolated static func requiresLFSRuntime(arguments: [String]) -> Bool { + switch arguments.first { + case "rev-parse", "rev-list", "merge-base", "for-each-ref", "show-ref", "check-ref-format": + return false + case "config": + return arguments.contains("--edit") || arguments.contains("-e") + case "branch": + return arguments != ["branch", "--show-current"] + case "remote": + return arguments.count != 1 && arguments.dropFirst().first != "get-url" + case "log": + // Require the final option to suppress diffs, so textconv/external diff + // drivers cannot invoke LFS. Do not mistake a path for this option. + return arguments.last != "--no-patch" || arguments.contains("--") + default: + return true + } + } + func gitExecutionContext( + requiresLFS: Bool = true, environment: [String: String] = ProcessInfo.processInfo.environment ) async throws -> (executable: String, environment: [String: String]) { let executableURL = try await runtimeManager.executableURL() @@ -271,16 +292,28 @@ actor GitStatusService { inheriting: environment ) resolvedEnvironment["PATH"] = executableURL.deletingLastPathComponent().path + ":" + (resolvedEnvironment["PATH"] ?? "/usr/bin:/bin") + guard requiresLFS else { return (executableURL.path, resolvedEnvironment) } if resolvedEnvironment["GIT_EXEC_PATH"] == nil { + let pathTask: Task if let cached = gitCorePaths[executableURL.path] { - resolvedEnvironment["GIT_EXEC_PATH"] = cached + pathTask = cached } else { - let result = try await GitProcessExecution(executable: executableURL.path, arguments: ["--exec-path"], - directory: FileManager.default.temporaryDirectory, environment: resolvedEnvironment, outputByteLimit: 16_384).run() - let path = String(decoding: result.data, as: UTF8.self).trimmingCharacters(in: .whitespacesAndNewlines) - gitCorePaths[executableURL.path] = path - resolvedEnvironment["GIT_EXEC_PATH"] = path + // Share the in-flight lookup across the Welcome screen's concurrent + // repository reads, as well as caching its completed result. + let processEnvironment = resolvedEnvironment + pathTask = Task { + do { + let result = try await GitProcessExecution(executable: executableURL.path, arguments: ["--exec-path"], + directory: FileManager.default.temporaryDirectory, environment: processEnvironment, outputByteLimit: 16_384).run() + return String(decoding: result.data, as: UTF8.self).trimmingCharacters(in: .whitespacesAndNewlines) + } catch { + gitCorePaths[executableURL.path] = nil + throw error + } + } + gitCorePaths[executableURL.path] = pathTask } + resolvedEnvironment["GIT_EXEC_PATH"] = try await pathTask.value } return (executableURL.path, try await lfsRuntime.environment(inheriting: resolvedEnvironment)) } @@ -380,7 +413,9 @@ actor GitStatusService { let startedAt = Date.now do { - let context = try await gitExecutionContext(environment: environment) + let context = try await gitExecutionContext( + requiresLFS: Self.requiresLFSRuntime(arguments: arguments), environment: environment + ) let execution = GitProcessExecution( executable: context.executable, arguments: arguments, @@ -421,7 +456,9 @@ actor GitStatusService { func runGitRaw(arguments: [String], in directory: URL, environment: [String: String], outputByteLimit: Int? = nil) async throws -> Data { let startedAt = Date() do { - let context = try await gitExecutionContext(environment: environment) + let context = try await gitExecutionContext( + requiresLFS: Self.requiresLFSRuntime(arguments: arguments), environment: environment + ) let result = try await GitProcessExecution( executable: context.executable, arguments: arguments, diff --git a/macgitTests/GitLFSRuntimeTests.swift b/macgitTests/GitLFSRuntimeTests.swift index e3cf73c9..a6fb1705 100644 --- a/macgitTests/GitLFSRuntimeTests.swift +++ b/macgitTests/GitLFSRuntimeTests.swift @@ -4,6 +4,78 @@ import XCTest @MainActor final class GitLFSRuntimeTests: XCTestCase { + func testMetadataExecutionDoesNotProbeLFS() async throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent("lfs-metadata-\(UUID())") + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + let suite = "lfs-metadata-\(UUID())" + let defaults = try XCTUnwrap(UserDefaults(suiteName: suite)) + defer { defaults.removePersistentDomain(forName: suite) } + let gitManager = GitRuntimeManager(configuration: GitRuntimeConfiguration( + applicationSupportDirectory: root, candidateSystemGitURLs: [URL(fileURLWithPath: "/usr/bin/git")], + manifest: .current, preferenceDefaults: defaults, preferenceKey: "gitPreference")) + let runner = CountingLFSVersionRunner() + let lfsManager = GitRuntimeManager(configuration: GitRuntimeConfiguration( + applicationSupportDirectory: root, candidateSystemGitURLs: [URL(fileURLWithPath: "/bin/sh")], + manifest: GitLFSRuntime.manifest, preferenceDefaults: defaults, preferenceKey: "lfsPreference", + managedDirectoryName: "GitLFS", executableRelativePath: "git-lfs-3.8.0/git-lfs", versionPrefix: "git-lfs/"), + processRunner: runner) + let commands = root.appendingPathComponent("commands") + let runtime = GitLFSRuntime(manager: lfsManager, commandDirectory: commands) + let service = GitStatusService(runtimeManager: gitManager, lfsRuntime: runtime) + // Exercise both production process paths, without a mock Git command runner. + let arguments = ["check-ref-format", "refs/heads/topic"] + let output = try await service.runGit(arguments: arguments, in: root) + XCTAssertEqual(output, "") + let bounded = try await service.runGitBounded(arguments: arguments, in: root, + environment: ProcessInfo.processInfo.environment, outputByteLimit: 1024) + XCTAssertEqual(bounded.text, "") + let count = await runner.count + XCTAssertEqual(count, 0) + XCTAssertFalse(FileManager.default.fileExists(atPath: commands.path)) + + _ = try await runtime.executable() + let resolvedCount = await runner.count + XCTAssertEqual(resolvedCount, 1, "LFS must still resolve when explicitly requested") + } + + func testConcurrentStartupReadsShareOneRuntimeProbe() async throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent("lfs-startup-\(UUID())") + defer { try? FileManager.default.removeItem(at: root) } + let suite = "lfs-startup-\(UUID())" + let defaults = try XCTUnwrap(UserDefaults(suiteName: suite)) + defer { defaults.removePersistentDomain(forName: suite) } + let runner = CountingLFSVersionRunner() + let executable = URL(fileURLWithPath: "/bin/sh") + let configuration = GitRuntimeConfiguration(applicationSupportDirectory: root, candidateSystemGitURLs: [executable], + manifest: GitLFSRuntime.manifest, preferenceDefaults: defaults, preferenceKey: "lfsPreference", + managedDirectoryName: "GitLFS", executableRelativePath: "git-lfs-3.8.0/git-lfs", versionPrefix: "git-lfs/") + let manager = GitRuntimeManager(configuration: configuration, processRunner: runner) + let runtime = GitLFSRuntime(manager: manager, commandDirectory: root.appendingPathComponent("commands")) + + try await withThrowingTaskGroup(of: Void.self) { group in + for index in 0..<32 { + group.addTask { + if index.isMultiple(of: 2) { + _ = try await runtime.environment(inheriting: ["PATH": "/usr/bin:/bin"]) + } else { + _ = try await runtime.executable() + } + } + } + try await group.waitForAll() + } + let startupCount = await runner.count + XCTAssertEqual(startupCount, 1, "Concurrent Welcome reads should share the first LFS probe") + _ = try await runtime.executable() + let cachedCount = await runner.count + XCTAssertEqual(cachedCount, 1) + + _ = await runtime.status() + let refreshedCount = await runner.count + XCTAssertEqual(refreshedCount, 2, "Explicit Settings refresh must still probe again") + } + func testEmbeddedInstallVerifiesAndSelectsPrivateRuntime() async throws { guard let source = ProcessInfo.processInfo.environment["COMMITPLUS_TEST_LFS_ARCHIVE"] else { throw XCTSkip("Set COMMITPLUS_TEST_LFS_ARCHIVE to the official archive for this architecture.") @@ -40,3 +112,14 @@ private struct LFSFixtureDownloader: GitRuntimeDownloading { return copy } } + +private actor CountingLFSVersionRunner: GitRuntimeProcessRunning { + private(set) var count = 0 + + func version(at executableURL: URL) async throws -> String { + count += 1 + // Keep discovery suspended while the other startup callers enter the actor. + try await Task.sleep(for: .milliseconds(50)) + return "git-lfs/3.8.0" + } +} diff --git a/macgitTests/GitLFSTests.swift b/macgitTests/GitLFSTests.swift index 20de5f05..96534816 100644 --- a/macgitTests/GitLFSTests.swift +++ b/macgitTests/GitLFSTests.swift @@ -3,6 +3,29 @@ import XCTest @testable import macgit final class GitLFSTests: XCTestCase { + func testMetadataCommandsDoNotRequireLFSRuntime() { + for arguments in [ + ["branch", "--show-current"], ["remote", "get-url", "origin"], + ["config", "user.email"], ["rev-parse", "--git-dir"], + ["rev-list", "--count", "--left-right", "HEAD...@{upstream}"], + ["log", "--all", "--format=%H%x09%ae%x09%ct", "--no-patch"] + ] { + XCTAssertFalse(GitStatusService.requiresLFSRuntime(arguments: arguments), "\(arguments)") + } + } + + func testFiltersHooksAndUnknownCommandsRetainLFSRuntime() { + for arguments in [ + ["status", "--porcelain"], ["add", "file.dat"], ["checkout", "main"], + ["push", "origin"], ["commit", "-m", "message"], ["lfs", "env"], + ["diff"], ["show", "HEAD:file.dat"], ["log", "-p"], + ["log", "--no-patch", "-p"], ["log", "-p", "--", "--no-patch"], + ["-c", "alias.custom=status", "custom"], ["branch", "-D", "topic"], [] + ] { + XCTAssertTrue(GitStatusService.requiresLFSRuntime(arguments: arguments), "\(arguments)") + } + } + @MainActor func testDownloadCredentialCallbackPreservesMainActorAcrossSuspension() async { var requestedRemote: String? From 032dcca916ad5d0cce18c3098199e7f29fa2b86f Mon Sep 17 00:00:00 2001 From: Thanh Tran Date: Sat, 26 Sep 2026 08:26:20 +0700 Subject: [PATCH 4/8] feat: add limit feature for git lfs --- AGENTS.md | 8 +- macgit/Models/FeatureAccessNotice.swift | 2 + macgit/Models/FeatureAccessPolicy.swift | 8 +- macgit/Services/FeaturePolicyStore.swift | 2 +- .../ViewModels/RepositoryLFSController.swift | 18 ++++- macgit/Views/Common/ProUpgradeSheet.swift | 3 +- macgit/Views/LFS/GitLFSAccessView.swift | 80 +++++++++++++++++++ .../LFS/GitLFSPreviewDownloadButton.swift | 12 ++- macgit/Views/LFS/GitLFSView.swift | 5 +- macgit/Views/MainWindow/ContentView.swift | 1 + macgit/Views/MainWindow/MainWindowView.swift | 14 ++-- macgitTests/FeatureAccessPolicyTests.swift | 34 ++++++++ macgitTests/GitLFSTests.swift | 2 +- 13 files changed, 171 insertions(+), 18 deletions(-) create mode 100644 macgit/Views/LFS/GitLFSAccessView.swift diff --git a/AGENTS.md b/AGENTS.md index 5da22bf2..0a39da19 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -24,7 +24,13 @@ See `README.md` for features and `CONTRIBUTING.md` for setup and coding conventi - `command-line/`: the `commit` CLI that opens repositories in Commit+. - `scripts/`: CLI build/tests and release tooling; `.github/workflows/`: CI and release automation. -Firebase backend code, rules, and backend tests live in the separate `landing-page` repository. Native client configuration is documented in `docs/firebase-setup.md`. +## Firebase Ownership + +- Firebase backend logic has moved to the sibling `../landing-page` repository. Make changes to Firestore rules, Cloud Functions, backend tests, and feature-policy provisioning scripts there, following that repository's `AGENTS.md`. +- Do not recreate or maintain Firebase backend logic in `macgit`. This repository owns only native Firebase client integration, local policy fallbacks, and app-side access checks. +- For plan/feature changes, inspect the feature policy in `../landing-page` and keep its configuration aligned with the native client when needed. Distinguish local changes from deployed Firebase changes; do not claim deployment without verification. +- Do not run Firebase Emulator or emulator-backed tests, including through wrapper scripts. Use source review, syntax checks, and relevant builds; report emulator tests as not run. +- Native client configuration is documented in `docs/firebase-setup.md`. ## Implementation Rules diff --git a/macgit/Models/FeatureAccessNotice.swift b/macgit/Models/FeatureAccessNotice.swift index f6b7f28e..b997750e 100644 --- a/macgit/Models/FeatureAccessNotice.swift +++ b/macgit/Models/FeatureAccessNotice.swift @@ -76,6 +76,8 @@ extension PlanFeature { "Private repositories" case .pullRequests: "Pull Requests" + case .gitLFS: + "Git LFS" case .gitFlow: "Git Flow" case .aiCommitMessage: diff --git a/macgit/Models/FeatureAccessPolicy.swift b/macgit/Models/FeatureAccessPolicy.swift index b8a356b5..58523f31 100644 --- a/macgit/Models/FeatureAccessPolicy.swift +++ b/macgit/Models/FeatureAccessPolicy.swift @@ -22,6 +22,7 @@ enum PlanFeature: String, CaseIterable, Codable, Hashable { case privateRepositories case pullRequests case gitFlow + case gitLFS case aiCommitMessage case repositoryChat case repositoryAIActions @@ -76,7 +77,7 @@ struct FeatureAccessPolicy: Codable, Equatable { static let bundled = FeatureAccessPolicy( schemaVersion: supportedSchemaVersion, - revision: 5, + revision: 6, features: [ .privateRepositories: FeaturePolicyRule( enabled: true, @@ -88,6 +89,11 @@ struct FeatureAccessPolicy: Codable, Equatable { free: PlanFeatureRule(enabled: true, repositoryScope: .public), pro: PlanFeatureRule(enabled: true, repositoryScope: .all) ), + .gitLFS: FeaturePolicyRule( + enabled: true, + free: PlanFeatureRule(enabled: true, repositoryScope: .public), + pro: PlanFeatureRule(enabled: true, repositoryScope: .all) + ), .gitFlow: FeaturePolicyRule( enabled: true, free: PlanFeatureRule(enabled: true, repositoryScope: .publicOrLocal), diff --git a/macgit/Services/FeaturePolicyStore.swift b/macgit/Services/FeaturePolicyStore.swift index ead31e65..780fb423 100644 --- a/macgit/Services/FeaturePolicyStore.swift +++ b/macgit/Services/FeaturePolicyStore.swift @@ -96,7 +96,7 @@ enum FeaturePolicyDocumentDecoder { private extension PlanFeature { var requiresRepositoryScope: Bool { switch self { - case .privateRepositories, .pullRequests, .gitFlow: + case .privateRepositories, .pullRequests, .gitFlow, .gitLFS: true case .aiCommitMessage, .repositoryChat, .repositoryAIActions, .aiConflictResolution, .aiBringYourOwnKey, .multipleProviderAccounts: diff --git a/macgit/ViewModels/RepositoryLFSController.swift b/macgit/ViewModels/RepositoryLFSController.swift index 24409443..ee2aa87e 100644 --- a/macgit/ViewModels/RepositoryLFSController.swift +++ b/macgit/ViewModels/RepositoryLFSController.swift @@ -20,10 +20,15 @@ final class RepositoryLFSController { private var operation: Task? private var generation = 0 - init(repository: URL) { self.repository = repository } + private let authorizeAction: @MainActor () async -> Bool + + init(repository: URL, authorizeAction: @escaping @MainActor () async -> Bool) { + self.repository = repository + self.authorizeAction = authorizeAction + } func load(promptForRuntime: Bool = false) async { - guard operation == nil else { return } + guard operation == nil, await authorizeAction() else { return } generation += 1 let generation = generation isLoading = true @@ -42,6 +47,7 @@ final class RepositoryLFSController { } func prepareRule(pattern: String, literal: Bool, removing: Bool) async { + guard await authorizeAction() else { return } do { review = try await GitStatusService.shared.reviewLFSTracking(pattern: pattern, literal: literal, removing: removing, in: repository) } catch { self.error = error.localizedDescription } @@ -55,6 +61,11 @@ final class RepositoryLFSController { error = nil notice = nil operation = Task { + guard await authorizeAction() else { + operation = nil + operationLabel = nil + return + } do { try await action(); notice = "Completed. Review any changes in File Status before committing." } catch { self.error = Task.isCancelled ? "Operation cancelled. Repository state has been refreshed; completed changes were retained." : error.localizedDescription } operation = nil @@ -70,7 +81,7 @@ final class RepositoryLFSController { } func scanLargeFiles(minimumMiB: Int) async { - guard !isScanning else { return } + guard !isScanning, await authorizeAction() else { return } isScanning = true defer { isScanning = false } do { @@ -80,6 +91,7 @@ final class RepositoryLFSController { } func prepareConversion(path: String) async { + guard await authorizeAction() else { return } do { conversion = try await GitStatusService.shared.reviewLFSConversion(path: path, in: repository) } catch { self.error = error.localizedDescription } } diff --git a/macgit/Views/Common/ProUpgradeSheet.swift b/macgit/Views/Common/ProUpgradeSheet.swift index 212bc5c3..dfcd5321 100644 --- a/macgit/Views/Common/ProUpgradeSheet.swift +++ b/macgit/Views/Common/ProUpgradeSheet.swift @@ -36,6 +36,7 @@ struct ProUpgradeSheet: View { (feature: "Number of devices", free: "1", pro: "3"), (feature: "BYOK AI providers", free: "OpenAI & Gemini", pro: "Full"), (feature: "View and manage PRs", free: "Public repositories", pro: "Full"), + (feature: "Git LFS", free: "Public repositories", pro: "Full"), (feature: "Git Flow", free: "Public & local repositories", pro: "Full"), (feature: "Git provider accounts", free: "1", pro: "Unlimited"), ] @@ -161,7 +162,7 @@ struct ProUpgradeSheet: View { private var upgradeDescription: String { switch feature { - case .pullRequests, .gitFlow: + case .pullRequests, .gitFlow, .gitLFS: "Upgrade to Commit+ Pro to use \(feature.displayName) in private repositories, plus advanced workflows and AI tools across your Macs." case .aiCommitMessage, .repositoryChat, .repositoryAIActions, .aiConflictResolution, .aiBringYourOwnKey, .multipleProviderAccounts: diff --git a/macgit/Views/LFS/GitLFSAccessView.swift b/macgit/Views/LFS/GitLFSAccessView.swift new file mode 100644 index 00000000..f0be245b --- /dev/null +++ b/macgit/Views/LFS/GitLFSAccessView.swift @@ -0,0 +1,80 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later +import SwiftUI + +/// Shared plan boundary for LFS management and explicit preview downloads. +struct GitLFSAccessView: View { + let repositoryURL: URL + @ViewBuilder let content: (@escaping @MainActor () async -> Bool) -> Content + @EnvironmentObject private var accountController: AccountSessionController + @EnvironmentObject private var featureAccessController: FeatureAccessController + @EnvironmentObject private var repositoryVisibilityController: RepositoryVisibilityController + @EnvironmentObject private var providerAccountController: GitProviderAccountController + @State private var decision: FeatureAccessDecision? + @State private var resolvedTaskID: String? + @State private var showUpgrade = false + + var body: some View { + Group { + if resolvedTaskID != taskID { + ProgressView("Checking Git LFS access…") + } else { + switch decision { + case .allowed: + content { await authorize() } + case .denied(let denial): + FeatureAccessUnavailableView( + notice: FeatureAccessNotice(feature: .gitLFS, denial: denial), + isSignedIn: accountController.account != nil, + onAccountAction: { + if accountController.account == nil { + accountController.presentAuthentication(.signIn) + } else { + showUpgrade = true + } + }, + onRetry: { Task { _ = await authorize(forceRefresh: true) } } + ) + case nil: + ProgressView("Checking Git LFS access…") + } + } + } + .task(id: taskID) { _ = await authorize() } + .sheet(isPresented: $showUpgrade) { + ProUpgradeSheet( + feature: .gitLFS, + isSignedIn: accountController.account != nil, + isOpening: accountController.openingWebDestination == .pricing, + errorMessage: accountController.errorMessage, + onCancel: { showUpgrade = false }, + onPrimaryAction: { Task { await accountController.openPricingOnWeb() } } + ) + } + } + + private var taskID: String { + [repositoryURL.absoluteString, + String(describing: featureAccessController.policy.rule(for: .gitLFS)), + String(describing: accountController.entitlement), + String(describing: providerAccountController.accounts)].joined(separator: "|") + } + + @MainActor + private func authorize(forceRefresh: Bool = false) async -> Bool { + let requestID = taskID + let visibility = await repositoryVisibilityController.resolve( + repositoryURL: repositoryURL, + accounts: providerAccountController.accounts, + forceRefresh: forceRefresh + ) + guard !Task.isCancelled, requestID == taskID else { return false } + let result = featureAccessController.decision( + for: .gitLFS, + entitlement: accountController.entitlement, + repositoryVisibility: visibility + ) + decision = result + resolvedTaskID = requestID + return result.isAllowed + } +} diff --git a/macgit/Views/LFS/GitLFSPreviewDownloadButton.swift b/macgit/Views/LFS/GitLFSPreviewDownloadButton.swift index 03904472..d0b26aa8 100644 --- a/macgit/Views/LFS/GitLFSPreviewDownloadButton.swift +++ b/macgit/Views/LFS/GitLFSPreviewDownloadButton.swift @@ -8,11 +8,19 @@ struct GitLFSPreviewDownloadButton: View { @State private var showDownload = false var body: some View { + GitLFSAccessView(repositoryURL: controller.repositoryURL) { authorize in + downloadButton(authorize: authorize) + } + } + + @ViewBuilder + private func downloadButton(authorize: @escaping @MainActor () async -> Bool) -> some View { Button("Download for Preview") { Task { + guard await authorize() else { return } await runtime.refresh() if runtime.status?.activeRuntime == nil { showDownload = true } - else { controller.downloadLFSPreview(remote: remote) } + else if await authorize() { controller.downloadLFSPreview(remote: remote) } } } .disabled(remote.isEmpty || controller.isLoadingPreview || runtime.isInstalling) @@ -21,7 +29,7 @@ struct GitLFSPreviewDownloadButton: View { let selectedID = controller.selectedEntry?.id Task { let installed = await runtime.install() - if installed, controller.selectedEntry?.id == selectedID { + if installed, controller.selectedEntry?.id == selectedID, await authorize() { controller.downloadLFSPreview(remote: remote) } } diff --git a/macgit/Views/LFS/GitLFSView.swift b/macgit/Views/LFS/GitLFSView.swift index 655e022e..beb90e18 100644 --- a/macgit/Views/LFS/GitLFSView.swift +++ b/macgit/Views/LFS/GitLFSView.swift @@ -19,14 +19,15 @@ struct GitLFSView: View { @State private var showingSetup = false init(repositoryURL: URL, initialPath: String? = nil, credentialResolver: @escaping @MainActor (String) async -> GitProviderCredentialResolver?, - refreshRepository: @escaping @MainActor @Sendable () async -> Void) { + refreshRepository: @escaping @MainActor @Sendable () async -> Void, + authorizeAction: @escaping @MainActor () async -> Bool) { self.repositoryURL = repositoryURL self.credentialResolver = credentialResolver self.refreshRepository = refreshRepository _pattern = State(initialValue: initialPath ?? "") _literal = State(initialValue: initialPath != nil) _tab = State(initialValue: initialPath == nil ? "Files" : "Tracking Rules") - _controller = State(initialValue: RepositoryLFSController(repository: repositoryURL)) + _controller = State(initialValue: RepositoryLFSController(repository: repositoryURL, authorizeAction: authorizeAction)) } private var files: [GitLFSFile] { diff --git a/macgit/Views/MainWindow/ContentView.swift b/macgit/Views/MainWindow/ContentView.swift index 99aaed52..1b101dd6 100644 --- a/macgit/Views/MainWindow/ContentView.swift +++ b/macgit/Views/MainWindow/ContentView.swift @@ -236,6 +236,7 @@ struct ContentView: View { ) // Prefer the scene that opened browser sign-in. If it was closed (or the // app relaunched), allow another existing scene to receive the callback. + .environmentObject(providerAccountController) .environment(\.gitLFSCredentialResolver, providerAccountController.credentialResolver()) .handlesExternalEvents( preferring: preferredExternalEvents, diff --git a/macgit/Views/MainWindow/MainWindowView.swift b/macgit/Views/MainWindow/MainWindowView.swift index a737d2ae..17834078 100644 --- a/macgit/Views/MainWindow/MainWindowView.swift +++ b/macgit/Views/MainWindow/MainWindowView.swift @@ -1316,11 +1316,13 @@ struct MainWindowView: View { case .item(.search): SearchView(repositoryURL: repositoryURL) case .item(.gitLFS): - GitLFSView(repositoryURL: repositoryURL, initialPath: lfsTrackingPath, credentialResolver: { @MainActor remote in - await credentialResolverForRemoteOperation(remotes: [remote]) - }, refreshRepository: { - await syncState.refresh(repositoryURL: repositoryURL, force: true) - }) + GitLFSAccessView(repositoryURL: repositoryURL) { authorize in + GitLFSView(repositoryURL: repositoryURL, initialPath: lfsTrackingPath, credentialResolver: { @MainActor remote in + await credentialResolverForRemoteOperation(remotes: [remote]) + }, refreshRepository: { + await syncState.refresh(repositoryURL: repositoryURL, force: true) + }, authorizeAction: authorize) + } .id(repositoryURL) case .item(.gitFlow): GitFlowDashboardView( @@ -1398,7 +1400,7 @@ struct MainWindowView: View { Task { _ = await authorizeGitFlowAccess(forceRefresh: true) } - case .privateRepositories, .aiCommitMessage, .repositoryChat, .repositoryAIActions, + case .privateRepositories, .gitLFS, .aiCommitMessage, .repositoryChat, .repositoryAIActions, .aiConflictResolution, .aiBringYourOwnKey, .multipleProviderAccounts: break } diff --git a/macgitTests/FeatureAccessPolicyTests.swift b/macgitTests/FeatureAccessPolicyTests.swift index a8245bf5..bd9efe09 100644 --- a/macgitTests/FeatureAccessPolicyTests.swift +++ b/macgitTests/FeatureAccessPolicyTests.swift @@ -69,6 +69,40 @@ final class FeatureAccessPolicyTests: XCTestCase { ) } + func testGitLFSPlanAndRepositoryMatrix() { + let resolver = FeatureAccessResolver(policy: .bundled) + for entitlement in [AccountEntitlement.free, inactivePro] { + XCTAssertEqual(resolver.decision(for: .gitLFS, entitlement: entitlement, repositoryVisibility: .public), .allowed) + for visibility in [RepositoryVisibility.private, .local] { + XCTAssertEqual(resolver.decision(for: .gitLFS, entitlement: entitlement, repositoryVisibility: visibility), .denied(.requiresPro)) + } + } + for visibility in [RepositoryVisibility.public, .private, .local] { + XCTAssertEqual(resolver.decision(for: .gitLFS, entitlement: activePro, repositoryVisibility: visibility), .allowed) + } + for entitlement in [AccountEntitlement.free, activePro] { + XCTAssertEqual(resolver.decision(for: .gitLFS, entitlement: entitlement, repositoryVisibility: .unknown), .denied(.repositoryVisibilityUnavailable)) + } + } + + func testGitLFSLegacyAndMalformedPoliciesUseScopedFallback() { + let legacy = FeatureAccessPolicy(schemaVersion: 1, revision: 5, features: [:]) + XCTAssertEqual(legacy.rule(for: .gitLFS), FeatureAccessPolicy.bundled.rule(for: .gitLFS)) + let malformed = FeaturePolicyDocumentDecoder.decode(document(overrides: ["gitLFS": proOnlyRule()])) + XCTAssertEqual(malformed?.rule(for: .gitLFS), FeatureAccessPolicy.bundled.rule(for: .gitLFS)) + } + + func testGitLFSRemoteKillSwitchIsRespected() { + let policy = FeaturePolicyDocumentDecoder.decode(document(overrides: ["gitLFS": [ + "enabled": false, + "plans": [ + "free": ["enabled": true, "repositoryScope": "public"], + "pro": ["enabled": true, "repositoryScope": "all"] + ] + ]]))! + XCTAssertEqual(FeatureAccessResolver(policy: policy).decision(for: .gitLFS, entitlement: activePro, repositoryVisibility: .private), .denied(.featureDisabled)) + } + func testFreeAIAllowsChatAndGenerationButRequiresProForWorkflowsAndConflicts() { let resolver = FeatureAccessResolver(policy: .bundled) for feature in [PlanFeature.aiCommitMessage, .repositoryChat] { diff --git a/macgitTests/GitLFSTests.swift b/macgitTests/GitLFSTests.swift index 96534816..39d1d6b6 100644 --- a/macgitTests/GitLFSTests.swift +++ b/macgitTests/GitLFSTests.swift @@ -36,7 +36,7 @@ final class GitLFSTests: XCTestCase { MainActor.assertIsolated() requestedRemote = remote return nil - }, refreshRepository: {}) + }, refreshRepository: {}, authorizeAction: { true }) let result = await view.credentialResolver("origin") XCTAssertNil(result) XCTAssertEqual(requestedRemote, "origin") From f3bd2f641c6a256eedfc6ba748f1a3aa76695d71 Mon Sep 17 00:00:00 2001 From: Thanh Tran Date: Sat, 26 Sep 2026 20:01:31 +0700 Subject: [PATCH 5/8] fix: Show conflict badge inline and stabilize LFS loading state Keep file icons visible while displaying potential conflict badges alongside Git LFS chips, and skip conflict badges during in-progress operations. Show a full-size LFS progress view only on initial load to prevent an empty state flash. --- macgit/Views/FileStatus/FileStatusView.swift | 35 +++++++++++-------- .../PotentialConflictFileIndicator.swift | 13 ++++--- macgit/Views/LFS/GitLFSView.swift | 15 +++++--- 3 files changed, 41 insertions(+), 22 deletions(-) diff --git a/macgit/Views/FileStatus/FileStatusView.swift b/macgit/Views/FileStatus/FileStatusView.swift index b573d8cf..52bc3d7c 100644 --- a/macgit/Views/FileStatus/FileStatusView.swift +++ b/macgit/Views/FileStatus/FileStatusView.swift @@ -474,17 +474,10 @@ struct FileStatusView: View { .labelsHidden() .pointingHandCursor() - if isPotentialConflict { - PotentialConflictFileIndicator( - baseRef: currentBranchIntegrationStatus?.baseRef, - onOpenDetails: { presentPotentialConflictDetails(for: file) } - ) - } else { - Image(systemName: fileIcon(for: file)) - .foregroundStyle(fileColor(for: file)) - .font(.system(size: 14, weight: .medium)) - .frame(width: 18) - } + Image(systemName: fileIcon(for: file)) + .foregroundStyle(fileColor(for: file)) + .font(.system(size: 14, weight: .medium)) + .frame(width: 18) HStack(spacing: 0) { VStack(alignment: .leading, spacing: 1) { @@ -492,8 +485,21 @@ struct FileStatusView: View { Text(file.displayName) .font(.system(size: 13, weight: .medium)) .lineLimit(1) - if lfsPaths.contains(file.path) { - GitLFSChip() + .truncationMode(.middle) + if lfsPaths.contains(file.path) || isPotentialConflict { + HStack(spacing: 6) { + if lfsPaths.contains(file.path) { + GitLFSChip() + } + if isPotentialConflict { + PotentialConflictFileIndicator( + baseRef: currentBranchIntegrationStatus?.baseRef, + onOpenDetails: { presentPotentialConflictDetails(for: file) } + ) + } + } + .fixedSize(horizontal: true, vertical: false) + .layoutPriority(1) } } if let original = file.originalPath { @@ -1433,7 +1439,8 @@ struct FileStatusView: View { } private func hasPotentialConflict(_ file: StatusFile) -> Bool { - guard file.status != .conflict, + guard syncState.inProgressOperation == nil, + file.status != .conflict, let conflictPaths = currentBranchIntegrationStatus?.potentialConflictPaths else { return false diff --git a/macgit/Views/FileStatus/PotentialConflictFileIndicator.swift b/macgit/Views/FileStatus/PotentialConflictFileIndicator.swift index 7580e52e..79ce8138 100644 --- a/macgit/Views/FileStatus/PotentialConflictFileIndicator.swift +++ b/macgit/Views/FileStatus/PotentialConflictFileIndicator.swift @@ -25,12 +25,17 @@ struct PotentialConflictFileIndicator: View { @State private var showingDetails = false var body: some View { - Button("Show Potential Update Conflict", systemImage: "exclamationmark.triangle", action: showDetails) - .labelStyle(.iconOnly) - .font(.system(size: 14, weight: .medium)) + Button("Potential conflict", systemImage: "exclamationmark.triangle", action: showDetails) + .font(.caption.weight(.medium)) .foregroundStyle(.orange) .buttonStyle(.plain) - .frame(width: 20, height: 20) + .padding(.horizontal, 5) + .padding(.vertical, 1) + .overlay { + RoundedRectangle(cornerRadius: 3) + .strokeBorder(.orange.opacity(0.6), lineWidth: 1) + } + .fixedSize() .contentShape(Rectangle()) .help(helpText) .accessibilityHint(helpText) diff --git a/macgit/Views/LFS/GitLFSView.swift b/macgit/Views/LFS/GitLFSView.swift index beb90e18..80fb759c 100644 --- a/macgit/Views/LFS/GitLFSView.swift +++ b/macgit/Views/LFS/GitLFSView.swift @@ -17,6 +17,7 @@ struct GitLFSView: View { @State private var literal = false @State private var minimumMiB = 50 @State private var showingSetup = false + @State private var isInitialLoadPending = true init(repositoryURL: URL, initialPath: String? = nil, credentialResolver: @escaping @MainActor (String) async -> GitProviderCredentialResolver?, refreshRepository: @escaping @MainActor @Sendable () async -> Void, @@ -44,7 +45,10 @@ struct GitLFSView: View { Label(error, systemImage: "exclamationmark.triangle").foregroundStyle(.red).textSelection(.enabled) } if let notice = controller.notice { Text(notice).foregroundStyle(.secondary) } - if runtime.status?.activeRuntime == nil { + if controller.snapshot == nil && (isInitialLoadPending || controller.isLoading) { + ProgressView("Reading Git LFS…") + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else if runtime.status?.activeRuntime == nil { ContentUnavailableView { Label("Git LFS Is Required", systemImage: "externaldrive.badge.exclamationmark") } description: { @@ -66,10 +70,9 @@ struct GitLFSView: View { Text("Tracking Rules").tag("Tracking Rules") }.pickerStyle(.segmented) if tab == "Files" { fileTable } else { rules(snapshot.rules) } - } else if controller.isLoading { - ProgressView("Reading Git LFS…").frame(maxWidth: .infinity, maxHeight: .infinity) } else { ContentUnavailableView("Unable to Read Git LFS", systemImage: "exclamationmark.triangle", description: Text("Check the message above, then Refresh to try again.")) + .frame(maxWidth: .infinity, maxHeight: .infinity) } if let label = controller.operationLabel { HStack { @@ -87,8 +90,12 @@ struct GitLFSView: View { } } } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) .padding() - .task { await controller.load(promptForRuntime: true) } + .task { + await controller.load(promptForRuntime: true) + isInitialLoadPending = false + } .onReceive(NotificationCenter.default.publisher(for: .repositoryLocalStateDidRefresh)) { notification in guard let url = notification.object as? URL, url.standardizedFileURL == repositoryURL.standardizedFileURL else { return } Task { await controller.load() } From 3b97e3cac482dc8defd7f5778380edb66cc2bc60 Mon Sep 17 00:00:00 2001 From: Thanh Tran Date: Sat, 26 Sep 2026 21:34:58 +0700 Subject: [PATCH 6/8] Fix Git LFS review findings and clone recovery --- .../2026-09-23-git-lfs-implementation-plan.md | 2 +- macgit/App/RevisionBrowserController.swift | 1 + macgit/Services/GitLFSErrorMessage.swift | 9 +++++-- macgit/Services/GitLFSRuntime.swift | 12 +++------ macgit/Services/GitStatusService+Clone.swift | 27 +++++++++++++++---- macgit/Services/GitStatusService+LFS.swift | 26 +++++++++++++++++- macgit/Services/GitStatusService.swift | 6 ++--- macgit/Views/FileStatus/FileStatusView.swift | 7 +++-- .../LFS/GitLFSPreviewDownloadButton.swift | 10 ++++--- macgit/Views/LFS/GitLFSView.swift | 3 ++- macgit/Views/MainWindow/MainWindowView.swift | 1 + macgit/Views/MainWindow/RepoPickerView.swift | 3 +-- macgitTests/GitLFSIntegrationTests.swift | 6 ++--- macgitTests/GitLFSRuntimeTests.swift | 22 +++++++++++++++ macgitTests/GitLFSTests.swift | 18 +++++++++++++ 15 files changed, 122 insertions(+), 31 deletions(-) diff --git a/docs/plans/2026-09-23-git-lfs-implementation-plan.md b/docs/plans/2026-09-23-git-lfs-implementation-plan.md index a7bec857..2ba2e0ed 100644 --- a/docs/plans/2026-09-23-git-lfs-implementation-plan.md +++ b/docs/plans/2026-09-23-git-lfs-implementation-plan.md @@ -2,7 +2,7 @@ Date: 2026-09-23. Status: first-release implementation added; manual UI and live-provider verification remain outstanding. -Implementation reference: [Git LFS user guide](../git-lfs.md). The delivered UI keeps Git LFS visible in Workspace so new users can discover setup directly. Setup, tracking, and review are presented in that workspace rather than a separate three-page wizard. Explicit LFS downloads show file and byte progress through the CLI progress interface, with an indeterminate fallback. Tracking rules have a source-aware table and preserve raw output for inspection. Existing custom hooks require manual integration. History migration, locking, and pruning remain outside this release. +Implementation reference: [Git LFS user guide](../git-lfs.md). The delivered UI keeps Git LFS visible in Workspace so new users can discover setup directly. Setup, tracking, and review are presented in that workspace rather than a separate three-page wizard. Explicit LFS downloads show file and byte progress through the CLI progress interface, with an indeterminate fallback. Tracking rules have a source-aware table and preserve raw output for inspection. Setup automatically integrates existing hooks, including those in a custom `core.hooksPath`. History migration, locking, and pruning remain outside this release. ## 1. What is Git LFS? diff --git a/macgit/App/RevisionBrowserController.swift b/macgit/App/RevisionBrowserController.swift index 130f466d..8abfed45 100644 --- a/macgit/App/RevisionBrowserController.swift +++ b/macgit/App/RevisionBrowserController.swift @@ -136,6 +136,7 @@ final class RevisionBrowserController { guard let entry = selectedEntry, let snapshot, preview?.lfsPointer != nil else { return } previewTask?.cancel() let id = previewID + previewError = nil isLoadingPreview = true previewTask = Task { do { diff --git a/macgit/Services/GitLFSErrorMessage.swift b/macgit/Services/GitLFSErrorMessage.swift index 8335ef9d..d3f09151 100644 --- a/macgit/Services/GitLFSErrorMessage.swift +++ b/macgit/Services/GitLFSErrorMessage.swift @@ -6,7 +6,11 @@ nonisolated enum GitLFSErrorMessage { var result = message if let regex = try? NSRegularExpression(pattern: "https?://[^\\s]+") { for match in regex.matches(in: result, range: NSRange(result.startIndex..., in: result)).reversed() { - guard let range = Range(match.range, in: result), var url = URLComponents(string: String(result[range])) else { continue } + guard let range = Range(match.range, in: result) else { continue } + guard var url = URLComponents(string: String(result[range])) else { + result.replaceSubrange(range, with: "") + continue + } url.user = nil url.password = nil url.query = nil @@ -22,7 +26,8 @@ nonisolated enum GitLFSErrorMessage { let detail = sanitized(error.localizedDescription) let lower = detail.lowercased() let explanation: String - if lower.contains("401") || lower.contains("403") || lower.contains("authentication") || lower.contains("credentials") { + let httpAuthStatus = lower.range(of: #"(?:http(?:/\d(?:\.\d)?)?\s+|status(?: code)?[\s:=]+|error[\s:=]+)(?:401|403)\b|\b(?:401 unauthorized|403 forbidden)\b"#, options: .regularExpression) != nil + if httpAuthStatus || lower.contains("authentication") || lower.contains("credentials") { explanation = "Git LFS could not authenticate. Check the account for the LFS endpoint; it may differ from the Git remote." } else if lower.contains("quota") || lower.contains("bandwidth") { explanation = "The remote reported a Git LFS storage or bandwidth limit. Check the provider's usage settings." diff --git a/macgit/Services/GitLFSRuntime.swift b/macgit/Services/GitLFSRuntime.swift index f5a5c54c..d8900917 100644 --- a/macgit/Services/GitLFSRuntime.swift +++ b/macgit/Services/GitLFSRuntime.swift @@ -72,10 +72,11 @@ actor GitLFSRuntime { func environment(inheriting environment: [String: String]) async throws -> [String: String] { if !didResolve { _ = await status() } var result = environment + guard let cachedURL else { return result } // Git prepends its exec-path ahead of PATH. Embedded Git includes its own // git-lfs, so PATH alone cannot enforce the user's separate LFS choice. if let corePath = environment["GIT_EXEC_PATH"] { - let key = corePath + "|" + (cachedURL?.path ?? "missing") + let key = corePath + "|" + cachedURL.path let commands: URL if let cached = commandPaths[key] { commands = cached } else { @@ -95,12 +96,7 @@ actor GitLFSRuntime { try FileManager.default.createSymbolicLink(at: staging.appendingPathComponent(source.lastPathComponent), withDestinationURL: source) } let target = staging.appendingPathComponent("git-lfs") - if let cachedURL { - try FileManager.default.createSymbolicLink(at: target, withDestinationURL: cachedURL) - } else { - try "#!/bin/sh\necho 'Git LFS is unavailable. Open Git LFS in Commit+ to install it.' >&2\nexit 127\n".write(to: target, atomically: true, encoding: .utf8) - try FileManager.default.setAttributes([.posixPermissions: 0o700], ofItemAtPath: target.path) - } + try FileManager.default.createSymbolicLink(at: target, withDestinationURL: cachedURL) if !FileManager.default.fileExists(atPath: commands.path) { do { try FileManager.default.moveItem(at: staging, to: commands) } catch { if !FileManager.default.fileExists(atPath: commands.path) { throw error } } @@ -109,7 +105,7 @@ actor GitLFSRuntime { } result["GIT_EXEC_PATH"] = commands.path result["PATH"] = commands.path + ":" + (environment["PATH"] ?? "/usr/bin:/bin") - } else if let cachedURL { + } else { result["PATH"] = cachedURL.deletingLastPathComponent().path + ":" + (environment["PATH"] ?? "/usr/bin:/bin") } return result diff --git a/macgit/Services/GitStatusService+Clone.swift b/macgit/Services/GitStatusService+Clone.swift index 3028497e..1b9fa9c4 100644 --- a/macgit/Services/GitStatusService+Clone.swift +++ b/macgit/Services/GitStatusService+Clone.swift @@ -48,18 +48,35 @@ extension GitStatusService { // Clone Git data first so a failed LFS download never requires cloning again. _ = try await runGit(arguments: ["-c", "filter.lfs.process=", "-c", "filter.lfs.smudge=", "-c", "filter.lfs.required=false"] + arguments, in: parentURL, environment: environment) - let files = try await runGit(arguments: ["ls-files", "-z"], in: destinationURL) - let paths = files.split(separator: "\0").map(String.init) - guard try await !lfsPaths(paths, in: destinationURL).isEmpty else { return } guard downloadLFSContent else { return } do { - try await setupLFS(in: destinationURL) - try await downloadLFS(remote: "origin", in: destinationURL, credentialResolver: credentialResolver) + try await finishLFSClone(in: destinationURL, credentialResolver: credentialResolver) } catch { throw GitLFSCloneRecoveryError(repository: destinationURL, reason: error.localizedDescription) } } + /// Finish both initial clone downloads and recovery, including initialized nested submodules. + func finishLFSClone(in repository: URL, credentialResolver: GitProviderCredentialResolver? = nil) async throws { + let submodulePaths = try await runGit( + arguments: ["submodule", "foreach", "--quiet", "--recursive", #"printf '%s\0' "$PWD""#], in: repository) + let repositories = [repository] + submodulePaths.split(separator: "\0").map { URL(fileURLWithPath: String($0)) } + for checkout in repositories { + try Task.checkCancellation() + let files = try await runGit(arguments: ["ls-files", "-z"], in: checkout) + let paths = files.split(separator: "\0").map(String.init) + guard try await !lfsPaths(paths, in: checkout).isEmpty else { continue } + // Fresh clones have one remote; respect clone.defaultRemoteName, including in submodules. + let output = try await runGit(arguments: ["remote"], in: checkout) + let remotes = output.split(whereSeparator: \.isNewline).map(String.init) + guard let remote = remotes.count == 1 ? remotes.first : (remotes.contains("origin") ? "origin" : nil) else { + throw GitError.commandFailed("Cannot determine the clone remote for \(checkout.lastPathComponent). Open Git LFS and select its remote.") + } + try await setupLFS(in: checkout) + try await downloadLFS(remote: remote, in: checkout, credentialResolver: credentialResolver) + } + } + func remoteBranches(remoteURL: String) async throws -> [String] { let output = try await runGit( arguments: ["ls-remote", "--heads", remoteURL], diff --git a/macgit/Services/GitStatusService+LFS.swift b/macgit/Services/GitStatusService+LFS.swift index a83c3131..00c748b8 100644 --- a/macgit/Services/GitStatusService+LFS.swift +++ b/macgit/Services/GitStatusService+LFS.swift @@ -112,6 +112,15 @@ extension GitStatusService { defer { lfsMutations.remove(key) } let scope = (try? await runGit(arguments: ["config", "--bool", "extensions.worktreeConfig"], in: repository))? .trimmingCharacters(in: .whitespacesAndNewlines) == "true" ? "--worktree" : "--local" + let configKeys = ["filter.lfs.clean", "filter.lfs.smudge", "filter.lfs.process", "filter.lfs.required", "core.hooksPath"] + let configOutput = try await runGit(arguments: ["config", scope, "--null", "--list"], in: repository) + var previousConfig: [String: [String]] = [:] + for record in configOutput.split(separator: "\0") { + let fields = record.split(separator: "\n", maxSplits: 1, omittingEmptySubsequences: false) + let name = String(fields[0]) + guard configKeys.contains(name) else { continue } + previousConfig[name, default: []].append(fields.count == 2 ? String(fields[1]) : "true") + } let hook = try await lfsHookURL(in: repository) let contents = (try? String(contentsOf: hook, encoding: .utf8)) ?? "" // Repeated setup must not wrap our own dispatcher again. @@ -154,8 +163,23 @@ extension GitStatusService { // Publish only after the complete hook directory and filters are ready. _ = try await runGit(arguments: ["config", scope, "core.hooksPath", directory.path], in: repository) } catch { + let setupError = error + let savedConfig = previousConfig + // An unstructured task can roll back even when setup's task was cancelled. + try await Task { + let current = try await self.runGit(arguments: ["config", scope, "--null", "--list"], in: repository) + let currentKeys = Set(current.split(separator: "\0").map { String($0.prefix(while: { $0 != "\n" })) }) + for name in configKeys { + if currentKeys.contains(name) { + _ = try await self.runGit(arguments: ["config", scope, "--unset-all", name], in: repository) + } + for value in savedConfig[name] ?? [] { + _ = try await self.runGit(arguments: ["config", scope, "--add", name, value], in: repository) + } + } + }.value try? FileManager.default.removeItem(at: directory) - throw error + throw setupError } } diff --git a/macgit/Services/GitStatusService.swift b/macgit/Services/GitStatusService.swift index 240b6f34..36e34d11 100644 --- a/macgit/Services/GitStatusService.swift +++ b/macgit/Services/GitStatusService.swift @@ -201,9 +201,9 @@ nonisolated private final class GitProcessExecution: @unchecked Sendable { private static func terminateChildren(of pid: Int32) { var children = [Int32](repeating: 0, count: 4096) let capacity = Int32(children.count * MemoryLayout.size) - let byteCount = children.withUnsafeMutableBytes { proc_listchildpids(pid, $0.baseAddress, capacity) } - guard byteCount > 0 else { return } - for child in children.prefix(Int(byteCount) / MemoryLayout.size) where child > 0 && child != pid { + let count = children.withUnsafeMutableBytes { proc_listchildpids(pid, $0.baseAddress, capacity) } + guard count > 0 else { return } + for child in children.prefix(min(Int(count), children.count)) where child > 0 && child != pid { terminateChildren(of: child) kill(child, SIGTERM) } diff --git a/macgit/Views/FileStatus/FileStatusView.swift b/macgit/Views/FileStatus/FileStatusView.swift index 52bc3d7c..65545c34 100644 --- a/macgit/Views/FileStatus/FileStatusView.swift +++ b/macgit/Views/FileStatus/FileStatusView.swift @@ -49,6 +49,7 @@ struct FileStatusView: View { @ObservedObject private var integrationSettings = IntegrationSettingsStore.shared @State private var gitStatus: GitStatus = GitStatus(staged: [], unstaged: [], untracked: []) @State private var lfsPaths = Set() + @State private var stagedLFSPaths = Set() @State private var changedFiles: [StatusFile] = [] @State private var visibleStagedFileCount = 100 @State private var visibleChangedFileCount = 100 @@ -453,6 +454,7 @@ struct FileStatusView: View { } private func fileRow(file: StatusFile, isStaged: Bool) -> some View { + let isLFS = (isStaged ? stagedLFSPaths : lfsPaths).contains(file.path) let selectionKey = FileStatusSelectionKey(file: file, isStaged: isStaged) let isSelected = selectedFileKey == selectionKey let quickAction = FileStatusRowQuickAction(isStaged: isStaged) @@ -486,9 +488,9 @@ struct FileStatusView: View { .font(.system(size: 13, weight: .medium)) .lineLimit(1) .truncationMode(.middle) - if lfsPaths.contains(file.path) || isPotentialConflict { + if isLFS || isPotentialConflict { HStack(spacing: 6) { - if lfsPaths.contains(file.path) { + if isLFS { GitLFSChip() } if isPotentialConflict { @@ -1478,6 +1480,7 @@ struct FileStatusView: View { let paths = Set((loadedStatus.staged + loadedStatus.unstaged + loadedStatus.untracked).map(\.path)) lfsPaths = (try? await GitStatusService.shared.lfsPaths(Array(paths), in: repositoryURL)) ?? [] + stagedLFSPaths = (try? await GitStatusService.shared.lfsPaths(loadedStatus.staged.map(\.path), cached: true, in: repositoryURL)) ?? [] gitStatus = loadedStatus changedFiles = loadedStatus.unstaged + loadedStatus.untracked currentBranch = loadedCurrentBranch diff --git a/macgit/Views/LFS/GitLFSPreviewDownloadButton.swift b/macgit/Views/LFS/GitLFSPreviewDownloadButton.swift index d0b26aa8..eda74a8e 100644 --- a/macgit/Views/LFS/GitLFSPreviewDownloadButton.swift +++ b/macgit/Views/LFS/GitLFSPreviewDownloadButton.swift @@ -6,6 +6,7 @@ struct GitLFSPreviewDownloadButton: View { let remote: String @State private var runtime = GitLFSRuntimeController.shared @State private var showDownload = false + @State private var requestedEntryID: String? var body: some View { GitLFSAccessView(repositoryURL: controller.repositoryURL) { authorize in @@ -16,20 +17,23 @@ struct GitLFSPreviewDownloadButton: View { @ViewBuilder private func downloadButton(authorize: @escaping @MainActor () async -> Bool) -> some View { Button("Download for Preview") { + let selectedID = controller.selectedEntry?.id + requestedEntryID = selectedID Task { guard await authorize() else { return } await runtime.refresh() + guard controller.selectedEntry?.id == selectedID else { return } if runtime.status?.activeRuntime == nil { showDownload = true } - else if await authorize() { controller.downloadLFSPreview(remote: remote) } + else if await authorize(), controller.selectedEntry?.id == selectedID { controller.downloadLFSPreview(remote: remote) } } } .disabled(remote.isEmpty || controller.isLoadingPreview || runtime.isInstalling) .alert("Download Git LFS?", isPresented: $showDownload) { Button("Download & Continue") { - let selectedID = controller.selectedEntry?.id + let selectedID = requestedEntryID Task { let installed = await runtime.install() - if installed, controller.selectedEntry?.id == selectedID, await authorize() { + if installed, await authorize(), controller.selectedEntry?.id == selectedID { controller.downloadLFSPreview(remote: remote) } } diff --git a/macgit/Views/LFS/GitLFSView.swift b/macgit/Views/LFS/GitLFSView.swift index 80fb759c..990fe0a8 100644 --- a/macgit/Views/LFS/GitLFSView.swift +++ b/macgit/Views/LFS/GitLFSView.swift @@ -97,7 +97,8 @@ struct GitLFSView: View { isInitialLoadPending = false } .onReceive(NotificationCenter.default.publisher(for: .repositoryLocalStateDidRefresh)) { notification in - guard let url = notification.object as? URL, url.standardizedFileURL == repositoryURL.standardizedFileURL else { return } + let url = (notification.userInfo?["repositoryURL"] as? URL) ?? (notification.object as? URL) + guard let url, url.standardizedFileURL == repositoryURL.standardizedFileURL else { return } Task { await controller.load() } } .alert("Download Git LFS?", isPresented: $controller.showingRuntimePrompt) { diff --git a/macgit/Views/MainWindow/MainWindowView.swift b/macgit/Views/MainWindow/MainWindowView.swift index f46b7813..9772d0d2 100644 --- a/macgit/Views/MainWindow/MainWindowView.swift +++ b/macgit/Views/MainWindow/MainWindowView.swift @@ -1361,6 +1361,7 @@ struct MainWindowView: View { }, refreshRepository: { await syncState.refresh(repositoryURL: repositoryURL, force: true) }, authorizeAction: authorize) + .onDisappear { lfsTrackingPath = nil } } .id(repositoryURL) case .item(.gitFlow): diff --git a/macgit/Views/MainWindow/RepoPickerView.swift b/macgit/Views/MainWindow/RepoPickerView.swift index 8dbea6b3..83600103 100644 --- a/macgit/Views/MainWindow/RepoPickerView.swift +++ b/macgit/Views/MainWindow/RepoPickerView.swift @@ -1444,8 +1444,7 @@ struct CloneSheetView: View { guard lfsRuntime.status?.activeRuntime != nil else { throw GitError.commandFailed(lfsRuntime.error ?? "Git LFS installation was cancelled.") } - try await GitStatusService.shared.setupLFS(in: repository) - try await GitStatusService.shared.downloadLFS(remote: "origin", in: repository, credentialResolver: lfsCredentialResolver) + try await GitStatusService.shared.finishLFSClone(in: repository, credentialResolver: lfsCredentialResolver) onClone(repository) dismiss() } catch { diff --git a/macgitTests/GitLFSIntegrationTests.swift b/macgitTests/GitLFSIntegrationTests.swift index 20e098a8..303d5841 100644 --- a/macgitTests/GitLFSIntegrationTests.swift +++ b/macgitTests/GitLFSIntegrationTests.swift @@ -9,7 +9,7 @@ final class GitLFSIntegrationTests: XCTestCase { let resultFile = repo.appendingPathComponent("should-not-be-written") let task = Task { try await service.runProcessRaw(executableURL: URL(fileURLWithPath: "/bin/sh"), - arguments: ["-c", "sleep 2; echo unexpected > \"$RESULT_FILE\""], in: repo, + arguments: ["-c", "(sleep 2; echo unexpected > \"$RESULT_FILE\") & wait"], in: repo, environment: ["PATH": "/usr/bin:/bin", "RESULT_FILE": resultFile.path]) } try await Task.sleep(for: .milliseconds(200)) @@ -51,8 +51,8 @@ final class GitLFSIntegrationTests: XCTestCase { let clone = repo.deletingLastPathComponent().appendingPathComponent("clone") try await service.cloneRepository(remoteURL: remote.path, to: clone, checkoutBranch: "main", recurseSubmodules: false, downloadLFSContent: false) XCTAssertNotNil(GitLFSPointer(try String(contentsOf: clone.appendingPathComponent("asset.dat"), encoding: .utf8))) - try await service.setupLFS(in: clone) - try await service.downloadLFS(remote: "origin", in: clone, credentialResolver: nil) + _ = try await service.runGit(arguments: ["remote", "rename", "origin", "upstream"], in: clone) + try await service.finishLFSClone(in: clone) XCTAssertEqual(try Data(contentsOf: clone.appendingPathComponent("asset.dat")), content) let changed = Data("local edits".utf8) try changed.write(to: clone.appendingPathComponent("asset.dat")) diff --git a/macgitTests/GitLFSRuntimeTests.swift b/macgitTests/GitLFSRuntimeTests.swift index a6fb1705..1ac1b385 100644 --- a/macgitTests/GitLFSRuntimeTests.swift +++ b/macgitTests/GitLFSRuntimeTests.swift @@ -4,6 +4,28 @@ import XCTest @MainActor final class GitLFSRuntimeTests: XCTestCase { + func testMissingRuntimePreservesGitLookup() async throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent("lfs-missing-\(UUID())") + defer { try? FileManager.default.removeItem(at: root) } + let suite = "lfs-missing-\(UUID())" + let defaults = try XCTUnwrap(UserDefaults(suiteName: suite)) + defer { defaults.removePersistentDomain(forName: suite) } + let configuration = GitRuntimeConfiguration(applicationSupportDirectory: root, candidateSystemGitURLs: [], + manifest: GitLFSRuntime.manifest, preferenceDefaults: defaults, preferenceKey: "lfsPreference", + managedDirectoryName: "GitLFS", executableRelativePath: "git-lfs-3.8.0/git-lfs", versionPrefix: "git-lfs/") + let manager = GitRuntimeManager(configuration: configuration, processRunner: GitLFSVersionRunner()) + let commands = root.appendingPathComponent("commands") + let runtime = GitLFSRuntime(manager: manager, commandDirectory: commands) + let environment = ["PATH": "/usr/bin:/bin", "GIT_EXEC_PATH": "/nonexistent/git-core"] + let resolved = try await runtime.environment(inheriting: environment) + XCTAssertEqual(resolved, environment) + XCTAssertFalse(FileManager.default.fileExists(atPath: commands.path)) + do { + _ = try await runtime.executable() + XCTFail("Explicit LFS operations must still report the missing runtime") + } catch {} + } + func testMetadataExecutionDoesNotProbeLFS() async throws { let root = FileManager.default.temporaryDirectory.appendingPathComponent("lfs-metadata-\(UUID())") try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) diff --git a/macgitTests/GitLFSTests.swift b/macgitTests/GitLFSTests.swift index 39d1d6b6..e875e787 100644 --- a/macgitTests/GitLFSTests.swift +++ b/macgitTests/GitLFSTests.swift @@ -65,6 +65,24 @@ final class GitLFSTests: XCTestCase { XCTAssertFalse(safe.contains("private")) XCTAssertTrue(safe.contains("example.com/object")) } + func testDiagnosticsRedactMalformedURL() { + let safe = GitLFSErrorMessage.sanitized("download https://user:secret@[invalid?signature=private failed") + XCTAssertFalse(safe.contains("secret")) + XCTAssertFalse(safe.contains("private")) + XCTAssertTrue(safe.contains("")) + } + + func testAuthenticationClassificationRequiresHTTPContext() { + for detail in ["object does not exist: abc401def403", "object does not exist: 401 bytes"] { + let message = GitLFSErrorMessage.describe(NSError(domain: "test", code: 1, userInfo: [NSLocalizedDescriptionKey: detail])) + XCTAssertTrue(message.hasPrefix("The LFS object is missing")) + } + for detail in ["HTTP 401", "HTTP/1.1 403", "status code: 401", "403 Forbidden"] { + let message = GitLFSErrorMessage.describe(NSError(domain: "test", code: 1, userInfo: [NSLocalizedDescriptionKey: detail])) + XCTAssertTrue(message.hasPrefix("Git LFS could not authenticate")) + } + } + func testIncludeFiltersRejectAmbiguousSelections() throws { XCTAssertEqual(try GitStatusService.lfsIncludePaths(["Assets/a.dat", "日本語 space.dat"]), "/Assets/a.dat,/日本語 space.dat") for path in ["a,b.dat", "*.dat", "a[1].dat", "../outside", "line\nbreak", " trailing "] { From 516d78fd5064f61f6ac667f62ec1b6e34d6b9465 Mon Sep 17 00:00:00 2001 From: Thanh Tran Date: Sat, 26 Sep 2026 21:35:40 +0700 Subject: [PATCH 7/8] Preserve canonical hooks config key during LFS rollback --- macgit/Services/GitStatusService+LFS.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/macgit/Services/GitStatusService+LFS.swift b/macgit/Services/GitStatusService+LFS.swift index 00c748b8..c6eb63c5 100644 --- a/macgit/Services/GitStatusService+LFS.swift +++ b/macgit/Services/GitStatusService+LFS.swift @@ -112,7 +112,7 @@ extension GitStatusService { defer { lfsMutations.remove(key) } let scope = (try? await runGit(arguments: ["config", "--bool", "extensions.worktreeConfig"], in: repository))? .trimmingCharacters(in: .whitespacesAndNewlines) == "true" ? "--worktree" : "--local" - let configKeys = ["filter.lfs.clean", "filter.lfs.smudge", "filter.lfs.process", "filter.lfs.required", "core.hooksPath"] + let configKeys = ["filter.lfs.clean", "filter.lfs.smudge", "filter.lfs.process", "filter.lfs.required", "core.hookspath"] let configOutput = try await runGit(arguments: ["config", scope, "--null", "--list"], in: repository) var previousConfig: [String: [String]] = [:] for record in configOutput.split(separator: "\0") { From 6f42484efd573315e73ded8bf1f07b1e556d113f Mon Sep 17 00:00:00 2001 From: Thanh Tran Date: Sun, 27 Sep 2026 10:12:03 +0700 Subject: [PATCH 8/8] Preserve LFS setup failure when rollback fails --- macgit/Services/GitStatusService+LFS.swift | 32 +++++++++++++--------- 1 file changed, 19 insertions(+), 13 deletions(-) diff --git a/macgit/Services/GitStatusService+LFS.swift b/macgit/Services/GitStatusService+LFS.swift index c6eb63c5..b36fe7df 100644 --- a/macgit/Services/GitStatusService+LFS.swift +++ b/macgit/Services/GitStatusService+LFS.swift @@ -165,20 +165,26 @@ extension GitStatusService { } catch { let setupError = error let savedConfig = previousConfig - // An unstructured task can roll back even when setup's task was cancelled. - try await Task { - let current = try await self.runGit(arguments: ["config", scope, "--null", "--list"], in: repository) - let currentKeys = Set(current.split(separator: "\0").map { String($0.prefix(while: { $0 != "\n" })) }) - for name in configKeys { - if currentKeys.contains(name) { - _ = try await self.runGit(arguments: ["config", scope, "--unset-all", name], in: repository) + defer { try? FileManager.default.removeItem(at: directory) } + do { + // An unstructured task can roll back even when setup's task was cancelled. + try await Task { + let current = try await self.runGit(arguments: ["config", scope, "--null", "--list"], in: repository) + let currentKeys = Set(current.split(separator: "\0").map { String($0.prefix(while: { $0 != "\n" })) }) + for name in configKeys { + if currentKeys.contains(name) { + _ = try await self.runGit(arguments: ["config", scope, "--unset-all", name], in: repository) + } + for value in savedConfig[name] ?? [] { + _ = try await self.runGit(arguments: ["config", scope, "--add", name, value], in: repository) + } } - for value in savedConfig[name] ?? [] { - _ = try await self.runGit(arguments: ["config", scope, "--add", name, value], in: repository) - } - } - }.value - try? FileManager.default.removeItem(at: directory) + }.value + } catch { + throw GitError.commandFailed( + "Git LFS setup failed: \(setupError.localizedDescription)\n\nConfiguration rollback also failed: \(error.localizedDescription)\nRepository settings may be partially restored; repair Git LFS setup before pushing." + ) + } throw setupError } }