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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
49 changes: 49 additions & 0 deletions docs/git-lfs.md
Original file line number Diff line number Diff line change
@@ -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 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

**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).
6 changes: 4 additions & 2 deletions docs/plans/2026-09-23-git-lfs-implementation-plan.md
Original file line number Diff line number Diff line change
@@ -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. 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?

Expand Down Expand Up @@ -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.
21 changes: 21 additions & 0 deletions macgit/App/RevisionBrowserController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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<String> = []
Expand Down Expand Up @@ -131,6 +132,26 @@ final class RevisionBrowserController {
}
}

func downloadLFSPreview(remote: String) {
guard let entry = selectedEntry, let snapshot, preview?.lfsPointer != nil else { return }
previewTask?.cancel()
let id = previewID
previewError = nil
isLoadingPreview = true
Comment thread
Tranthanh98 marked this conversation as resolved.
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<Void, Never>? { folderTasks[path] }

func cancel() {
Expand Down
3 changes: 2 additions & 1 deletion macgit/App/RevisionBrowserWindowController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
2 changes: 2 additions & 0 deletions macgit/Models/FeatureAccessNotice.swift
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,8 @@ extension PlanFeature {
"Private repositories"
case .pullRequests:
"Pull Requests"
case .gitLFS:
"Git LFS"
case .gitFlow:
"Git Flow"
case .aiCommitMessage:
Expand Down
8 changes: 7 additions & 1 deletion macgit/Models/FeatureAccessPolicy.swift
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ enum PlanFeature: String, CaseIterable, Codable, Hashable {
case privateRepositories
case pullRequests
case gitFlow
case gitLFS
case aiCommitMessage
case repositoryChat
case repositoryAIActions
Expand Down Expand Up @@ -76,7 +77,7 @@ struct FeatureAccessPolicy: Codable, Equatable {

static let bundled = FeatureAccessPolicy(
schemaVersion: supportedSchemaVersion,
revision: 5,
revision: 6,
features: [
.privateRepositories: FeaturePolicyRule(
enabled: true,
Expand All @@ -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),
Expand Down
8 changes: 8 additions & 0 deletions macgit/Models/GitLFSCandidate.swift
Original file line number Diff line number Diff line change
@@ -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 }
}
10 changes: 10 additions & 0 deletions macgit/Models/GitLFSCloneRecoveryError.swift
Original file line number Diff line number Diff line change
@@ -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)"
}
}
10 changes: 10 additions & 0 deletions macgit/Models/GitLFSConversionReview.swift
Original file line number Diff line number Diff line change
@@ -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
}
13 changes: 13 additions & 0 deletions macgit/Models/GitLFSFile.swift
Original file line number Diff line number Diff line change
@@ -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") }
}
6 changes: 6 additions & 0 deletions macgit/Models/GitLFSFileList.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import Foundation

nonisolated struct GitLFSFileList: Decodable, Sendable {
let files: [GitLFSFile]?
}
22 changes: 22 additions & 0 deletions macgit/Models/GitLFSPointer.swift
Original file line number Diff line number Diff line change
@@ -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
}
}
11 changes: 11 additions & 0 deletions macgit/Models/GitLFSSnapshot.swift
Original file line number Diff line number Diff line change
@@ -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?
}
11 changes: 11 additions & 0 deletions macgit/Models/GitLFSTrackingReview.swift
Original file line number Diff line number Diff line change
@@ -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
}
27 changes: 27 additions & 0 deletions macgit/Models/GitLFSTrackingRule.swift
Original file line number Diff line number Diff line change
@@ -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<String>()
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)[..<separator.lowerBound])
let source = String(line[separator.upperBound...].dropLast())
let rule = Self(pattern: pattern, source: source, excluded: excluded)
if seen.insert(rule.id).inserted { result.append(rule) }
}
return result
}
}
27 changes: 27 additions & 0 deletions macgit/Models/GitLFSTransferProgress.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import Foundation

nonisolated struct GitLFSTransferProgress: Equatable, Sendable {
let direction: String
let fileIndex: Int
let fileCount: Int
let bytes: Int64
let totalBytes: Int64
let name: String

init?(_ line: String) {
let fields = line.split(separator: " ", maxSplits: 3)
guard fields.count == 4, ["download", "upload", "checkout"].contains(fields[0]) else { return nil }
let files = fields[1].split(separator: "/")
let sizes = fields[2].split(separator: "/")
guard files.count == 2, sizes.count == 2, let index = Int(files[0]), let count = Int(files[1]),
let bytes = Int64(sizes[0]), let total = Int64(sizes[1]), index > 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])
}
}
1 change: 1 addition & 0 deletions macgit/Models/RevisionFilePreview.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading
Loading