419 lines
22 KiB
Swift
419 lines
22 KiB
Swift
import Foundation
|
|
import SwiftGitX
|
|
import os
|
|
|
|
// MARK: - Failure
|
|
|
|
/// **Why a git operation didn't happen**, named and carrying libgit2's own message.
|
|
///
|
|
/// One type rather than a case per operation because everything that reaches a user goes through
|
|
/// the same two sentences — what was being attempted, and what the library said — and because the
|
|
/// operations that will join `initialize` here (commit, checkout, pull) all fail in exactly that
|
|
/// shape (06-history-undo.md ▸ Interaction with external writers: "An operation that fails
|
|
/// *cleanly* … surfaces as a one-shot banner failure naming the operation and the error").
|
|
public struct GitOperationFailure: Error, Sendable, Equatable, CustomStringConvertible {
|
|
|
|
/// What was being attempted, in the user's words rather than libgit2's — "Adding git to this
|
|
/// board", not `git_repository_init`.
|
|
public let operation: String
|
|
|
|
/// libgit2's message for the failure, verbatim. Kept rather than mapped: the messages are
|
|
/// specific ("could not write to '…': Permission denied") in a way no re-phrasing of ours would
|
|
/// be, and the alternative to showing it is a shrug.
|
|
public let message: String
|
|
|
|
public init(operation: String, message: String) {
|
|
self.operation = operation
|
|
self.message = message
|
|
}
|
|
|
|
public var description: String { "\(operation) failed: \(message)" }
|
|
}
|
|
|
|
// MARK: - GitRepository
|
|
|
|
/// **The board's repository, through the bundled libgit2** (06-history-undo.md ▸ Rules ▸ Opt-in
|
|
/// init: "Bundled libgit2 — no git install required").
|
|
///
|
|
/// SwiftGitX vendors libgit2 as an in-process library, so every call here runs inside the sandbox
|
|
/// with no `Process`, no `/usr/bin/git` and no sandbox extension — the shipped Release build behaves
|
|
/// identically on a machine that has never had the command-line tools installed.
|
|
///
|
|
/// ### Isolation
|
|
///
|
|
/// Every function is `nonisolated` and **opens its own `Repository`, confined to its own
|
|
/// synchronous scope**. `Repository` is `Sendable` (SwiftGitX marks it so to make handles
|
|
/// transferable), but the libgit2 handle underneath is not safe for concurrent use from several
|
|
/// threads at once, so no handle here is ever shared across an `await`, a `Task`, or a stored
|
|
/// property. `HistoryStore` — which is `@MainActor` — reaches these through `Task.detached`, so the
|
|
/// main actor never blocks on libgit2 and libgit2 never sees two threads at once.
|
|
///
|
|
/// This is the pathfinder's `GitSource` shape, kept because it was right, with the pathfinder's
|
|
/// *policy* deliberately left behind: nothing here auto-initializes anything and nothing commits on
|
|
/// its own schedule. It writes no seed of its own any more: the `.gitignore` outgrew git on
|
|
/// 2026-07-31 and belongs to the board now (`BoardWriter.gitignoreSeed`, seeded at creation and
|
|
/// healed in at open), so all that survives here is a last-chance check that the file exists before
|
|
/// the initial commit freezes the tree — see `seedGitignoreIfAbsent(at:)`.
|
|
enum GitRepository {
|
|
|
|
/// **The root commit's own subject** (06-history-undo.md ▸ Rules ▸ Abnormal repo states,
|
|
/// settled): "whenever the app creates a repo's first commit … it commits the whole tree as
|
|
/// *Initial board state*, never a folded diff-from-empty: there is no last-committed snapshot to
|
|
/// diff against, and forty Adds would bury the event."
|
|
static let initialCommitSubject = "Initial board state"
|
|
|
|
/// The branch a board's first commit lands on.
|
|
///
|
|
/// **Forced rather than inherited, deliberately.** libgit2's compiled-in initial-branch name
|
|
/// comes from `init.defaultBranch` in whatever config layer it can find at
|
|
/// `git_repository_init` time — which is non-deterministic across machines and simply
|
|
/// unavailable in the sandbox (redirected, empty HOME). `Repository.create(at:)` has no
|
|
/// initial-branch parameter, so this is applied by writing `.git/HEAD` directly: on a freshly
|
|
/// created, unborn, non-bare repository that file is nothing but the plain-text symbolic ref, so
|
|
/// writing it is exactly `git symbolic-ref HEAD refs/heads/main` before anything else touches
|
|
/// the repo.
|
|
///
|
|
/// **The initial branch is `main`** (06 ▸ Rules ▸ Opt-in init, blessed 2026-07-31): "the host's
|
|
/// `init.defaultBranch` lives in config layers the sandbox can't read, so add-git sets it
|
|
/// deterministically — git's modern default, the pathfinder's choice."
|
|
static let initialBranchName = "main"
|
|
|
|
private static let logger = Logger(subsystem: "dev.rzen.indie.Kanban", category: "git")
|
|
|
|
// MARK: Opt-in init
|
|
|
|
/// **Add-git** (06-history-undo.md ▸ Rules ▸ Opt-in init): initializes a repository at
|
|
/// `boardRoot` and immediately commits the whole tree as `Initial board state`.
|
|
///
|
|
/// The commit is not deferred to any debounce — "init doesn't wait for the debounce; the board
|
|
/// is protected from the moment git exists" — so the two halves are one operation and a failure
|
|
/// in either is one failure.
|
|
///
|
|
/// Between them sits a last-chance `.gitignore` check — the file is the board's rather than
|
|
/// git's since 2026-07-31, so it is almost always already there; when it is not, seeding it here
|
|
/// puts it *in* the initial commit rather than after it (`seedGitignoreIfAbsent`).
|
|
///
|
|
/// **Create re-runs full detection and refuses anything but clean mode none** (06 ▸ Rules ▸
|
|
/// Detection, ruled 2026-07-31): "as hardening, add-git's create re-runs full detection and
|
|
/// refuses unless it reads clean none, so the forbidden nested init is impossible even on a
|
|
/// raced or stale read."
|
|
///
|
|
/// The caller (`HistoryStore.addGit`) has already established mode `none` from the mode it
|
|
/// detected at board open, which can be minutes old — a `git init` in a terminal at the board root
|
|
/// *or anywhere above it* between the two would otherwise slip past a root-only check and
|
|
/// initialize a repository inside the user's, which is the one init 06 forbids outright. The whole
|
|
/// walk runs again here, at the moment of the write, so the refusal is structural rather than
|
|
/// probable. **`.unverifiable` refuses too** — a denied ancestor check can never be told apart
|
|
/// from a repository actually being there, so only a genuinely clean `.none` reading proceeds; a
|
|
/// stale `.none` that has since become unverifiable is refused exactly like one that has since
|
|
/// become repo-nested.
|
|
///
|
|
/// Returns the branch the root commit landed on, which is the popover's display line.
|
|
nonisolated static func create(at boardRoot: URL) -> Result<String, GitOperationFailure> {
|
|
let operation = "Adding git to this board"
|
|
|
|
switch BoardGitMode.detect(boardRoot: boardRoot) {
|
|
case .none:
|
|
break
|
|
case .git:
|
|
return .failure(GitOperationFailure(
|
|
operation: operation,
|
|
message: "this board already has a git repository"
|
|
))
|
|
case .repoNested:
|
|
return .failure(GitOperationFailure(
|
|
operation: operation,
|
|
message: "this board lives inside a repository; Lanework leaves it to that repository"
|
|
))
|
|
case .unverifiable:
|
|
return .failure(GitOperationFailure(
|
|
operation: operation,
|
|
message: "this board's surroundings could not be fully checked, so Lanework will not add a repository here"
|
|
))
|
|
}
|
|
|
|
let gitDirectory: URL
|
|
do {
|
|
let created = try Repository.create(at: boardRoot)
|
|
gitDirectory = created.path
|
|
// Before anything else touches the repo — see `initialBranchName`.
|
|
try? "ref: refs/heads/\(initialBranchName)\n".write(
|
|
to: gitDirectory.appendingPathComponent("HEAD"),
|
|
atomically: true,
|
|
encoding: .utf8
|
|
)
|
|
} catch {
|
|
return .failure(GitOperationFailure(operation: operation, message: reason(error)))
|
|
}
|
|
|
|
// **Before the stage below, so the seed is *in* the initial commit** (06 ▸ Repository
|
|
// hygiene). Ordering is the whole of it: written first, `.gitignore` is one of the paths
|
|
// `git status` reports and rides into "Initial board state" like any other file — and any
|
|
// `.DS_Store` the Finder already left under the board is ignored from the repository's very
|
|
// first commit rather than entering history and needing to be forgotten later, which nothing
|
|
// in this app will ever do (06 ▸ Deleting never forgets).
|
|
seedGitignoreIfAbsent(at: boardRoot)
|
|
|
|
// **The root commit goes through the same signature-capable path every later commit does**
|
|
// (`GitCommitOperation`), which is what retired this method's config materialization.
|
|
//
|
|
// Until the auto-commit card there was no way to hand libgit2 a signature through SwiftGitX
|
|
// — `commit(message:)` leaves `author`/`committer` null and libgit2 falls back to
|
|
// `git_signature_default`, which reads a merged config ladder the sandbox cannot see — so
|
|
// add-git wrote `user.name`/`user.email` into the fresh repository's own config to give that
|
|
// fallback something to find. That was an explicit interim, and it is gone: **nothing in the
|
|
// app writes those keys any more.** The identity resolves at commit time, in one place
|
|
// (`GitCommitOperation.userIdentity(at:)`), repo-local config winning over the derived
|
|
// default exactly as 06 states — and a repository the app created now looks like one `git
|
|
// init` made, with no opinion of ours baked into its config. The popover's identity fields
|
|
// (a later card) are what will write that file, because there "the setting *is* the file".
|
|
//
|
|
// Every path `git status` reports is staged — full `git add -A` semantics, `.gitignore`
|
|
// respected — which is what "commits the whole tree" means: the board's files, the agent
|
|
// guide, strays and all (06 ▸ Commit messages: "the committer stages the whole board root").
|
|
let identity = GitCommitOperation.userIdentity(at: boardRoot)
|
|
let outcome = GitCommitOperation.perform(
|
|
at: boardRoot,
|
|
commits: [PlannedCommit(
|
|
paths: GitCommitOperation.changedPaths(at: boardRoot).map(\.path),
|
|
message: initialCommitSubject,
|
|
author: identity,
|
|
committer: identity
|
|
)]
|
|
)
|
|
|
|
switch outcome {
|
|
case .committed:
|
|
return .success(branchName(at: boardRoot) ?? initialBranchName)
|
|
case .nothingToCommit:
|
|
// A board with no files at all — `git init` on an empty folder. The repository exists,
|
|
// which is what add-git promised; the first settled change takes the root commit through
|
|
// the ordinary engine (06 ▸ Rules ▸ Abnormal repo states: an unborn HEAD "is normal git
|
|
// mode"), and the branch line has a name to show either way.
|
|
return .success(branchName(at: boardRoot) ?? initialBranchName)
|
|
case .locked:
|
|
return .failure(GitOperationFailure(
|
|
operation: operation,
|
|
message: "another program is using this repository's index"
|
|
))
|
|
case let .held(pause):
|
|
return .failure(GitOperationFailure(operation: operation, message: pause.explanation))
|
|
case let .failed(failure):
|
|
logger.error("initial commit failed at \(boardRoot.path, privacy: .public): \(failure.message, privacy: .public)")
|
|
return .failure(GitOperationFailure(operation: operation, message: failure.message))
|
|
}
|
|
}
|
|
|
|
/// **The last-chance `.gitignore` seed, immediately before the initial commit.**
|
|
///
|
|
/// The seed itself stopped being git's on 2026-07-31 (06-history-undo.md ▸ Repository hygiene,
|
|
/// re-ruled: "`.gitignore` seeded on every board, never touched after … git or not"). Every board
|
|
/// the app creates is born with one, and every board it opens is healed into having one
|
|
/// (`BoardStore.seedGitignore`) — and add-git can only run on a board that is *open* and writable,
|
|
/// so by the time this line is reached the file is essentially always already there and this call
|
|
/// writes nothing.
|
|
///
|
|
/// **It stays anyway, and stays here — before the stage below.** The one case it still answers is
|
|
/// the one that cannot be fixed afterwards: if the board's seed heal has not landed (a transient
|
|
/// failure that armed its memo, a picture that has not changed since), the initial commit would
|
|
/// otherwise capture every `.DS_Store` the Finder has left under the board *into history*, where
|
|
/// this app has no operation that could ever remove it (06 ▸ Deleting never forgets). One
|
|
/// `lstat` on the one path that mints a repository is a cheap insurance policy against a
|
|
/// permanent record.
|
|
///
|
|
/// Seeding is `BoardWriter.seedGitignoreIfAbsent`'s — one seed text, one write-only-when-free
|
|
/// rule, `lstat` semantics — so this cannot drift from what board creation and the heal write.
|
|
///
|
|
/// A write that fails is not a failure of add-git. The repository exists, the commit that follows
|
|
/// simply will not carry a `.gitignore`, and the board's own heal will try again at the next
|
|
/// open — surfacing a banner about a courtesy file would be louder than the thing it reports.
|
|
private static func seedGitignoreIfAbsent(at boardRoot: URL) {
|
|
do {
|
|
try BoardWriter.seedGitignoreIfAbsent(atBoardRoot: boardRoot)
|
|
} catch {
|
|
logger.notice("could not seed .gitignore at \(boardRoot.path, privacy: .public): \(String(describing: error), privacy: .public)")
|
|
}
|
|
}
|
|
|
|
// MARK: Reads
|
|
|
|
/// The current branch's short name, or `nil` when there is no repository at `boardRoot` or
|
|
/// libgit2 cannot open it — the popover's read-only branch line (03-board-ui.md ▸ Board
|
|
/// popover), and nothing more: branch switching and creation are a later card.
|
|
///
|
|
/// **An unborn HEAD answers with a name, not with `nil`** (06 ▸ Rules ▸ Abnormal repo states:
|
|
/// "an unborn HEAD is normal git mode"). Every SwiftGitX HEAD accessor goes through
|
|
/// `git_repository_head`, which refuses to resolve an unborn HEAD to a name and throws instead,
|
|
/// so the only way to recover the branch a first commit *would* land on is to read `.git/HEAD`'s
|
|
/// symbolic-ref target — the same plain text this file writes at init.
|
|
nonisolated static func branchName(at boardRoot: URL) -> String? {
|
|
guard BoardGitMode.hasGitEntry(at: boardRoot),
|
|
let repository = try? Repository.open(at: boardRoot) else { return nil }
|
|
|
|
if repository.isHEADUnborn {
|
|
return unbornBranchName(gitDirectory: repository.path)
|
|
}
|
|
guard let head = try? repository.HEAD else { return nil }
|
|
if repository.isHEADDetached {
|
|
// Detached HEAD reports its branch `name` as the literal "HEAD", which labels nothing.
|
|
// The short hash is what plain git shows in the same state. (The *posture* a detached
|
|
// HEAD calls for — pausing the whole git surface honestly, 06 ▸ Abnormal repo states —
|
|
// is the auto-commit card's; this is only the label.)
|
|
return (head.target as? Commit)?.id.abbreviated ?? "HEAD"
|
|
}
|
|
return head.name
|
|
}
|
|
|
|
/// HEAD's commit, flattened to what a caller (and a test) can assert on: subject, author, and
|
|
/// how many parents it has — a root commit having none is how "the root commit has its own
|
|
/// subject" is checkable.
|
|
nonisolated static func headCommit(at boardRoot: URL) -> CommitSummary? {
|
|
guard BoardGitMode.hasGitEntry(at: boardRoot),
|
|
let repository = try? Repository.open(at: boardRoot),
|
|
!repository.isHEADUnborn,
|
|
let head = try? repository.HEAD,
|
|
let commit = head.target as? Commit else { return nil }
|
|
|
|
return CommitSummary(
|
|
oid: commit.id.hex,
|
|
subject: commit.summary,
|
|
authorName: commit.author.name,
|
|
authorEmail: commit.author.email,
|
|
parentCount: (try? commit.parents)?.count ?? 0
|
|
)
|
|
}
|
|
|
|
/// Every file path in HEAD's tree, board-root-relative and sorted — what the repository actually
|
|
/// tracks right now.
|
|
nonisolated static func trackedPaths(at boardRoot: URL) -> [String] {
|
|
guard BoardGitMode.hasGitEntry(at: boardRoot),
|
|
let repository = try? Repository.open(at: boardRoot),
|
|
!repository.isHEADUnborn,
|
|
let head = try? repository.HEAD,
|
|
let commit = head.target as? Commit else { return [] }
|
|
return filePaths(of: commit, in: repository).sorted()
|
|
}
|
|
|
|
/// One commit, as much of it as anything outside this file needs.
|
|
struct CommitSummary: Sendable, Equatable {
|
|
let oid: String
|
|
let subject: String
|
|
let authorName: String
|
|
let authorEmail: String
|
|
let parentCount: Int
|
|
}
|
|
|
|
// MARK: Path history
|
|
|
|
/// **When each path entered history** — the git half of the loader's earlier-occurrence-wins
|
|
/// ladder (01-storage-format.md ▸ Fractal layout ▸ Rules: "on git boards, the path history
|
|
/// already tracks outranks the newcomer"; `BoardLoader.IdentityHistoryRanker`).
|
|
///
|
|
/// The answer is `git log --diff-filter=A`-shaped, walked here rather than shelled out: HEAD's
|
|
/// **first-parent** ancestry oldest-first, with each commit's rank being its position in that
|
|
/// walk. Paths present in the oldest commit reached rank 0 (its whole tree, since a root commit
|
|
/// has no parent to diff against and a capped walk's base is "everything that already existed");
|
|
/// every later commit contributes the paths its diff *adds*. Lower is earlier, which is exactly
|
|
/// the ranker's contract, and a path never seen is absent — the `nil` the rule reads as
|
|
/// "outranked by anything tracked".
|
|
///
|
|
/// **Ranks are recorded for folders, not only files**, because the loader asks about *items*:
|
|
/// a card is a folder, and what git tracks is the `index.md` inside it. Every directory prefix
|
|
/// of an added file therefore takes that file's rank unless it already has an earlier one.
|
|
///
|
|
/// Two honest limits. The walk is **capped** (`limit`), so a board with a longer history than
|
|
/// that reads everything at its base as equally early — a tie the ladder resolves on birth date,
|
|
/// exactly as it does without git. And **renames are not followed**: libgit2 reports a rename as
|
|
/// an add plus a delete unless rename detection is run over the diff, so a card moved between
|
|
/// lanes ranks at its move rather than at its birth (`--follow`'s job). Both degrade toward the
|
|
/// no-history answer rather than toward a wrong one.
|
|
nonisolated static func pathFirstAppearanceRanks(at boardRoot: URL, limit: Int = 512) -> [String: Int] {
|
|
guard BoardGitMode.hasGitEntry(at: boardRoot),
|
|
let repository = try? Repository.open(at: boardRoot),
|
|
!repository.isHEADUnborn,
|
|
let head = try? repository.HEAD,
|
|
let tip = head.target as? Commit else { return [:] }
|
|
|
|
var chain: [Commit] = []
|
|
var current: Commit? = tip
|
|
while let commit = current, chain.count < limit {
|
|
chain.append(commit)
|
|
current = (try? commit.parents)?.first
|
|
}
|
|
|
|
var ranks: [String: Int] = [:]
|
|
for (rank, commit) in chain.reversed().enumerated() {
|
|
if rank == 0 {
|
|
for path in filePaths(of: commit, in: repository) {
|
|
record(path: path, rank: rank, into: &ranks)
|
|
}
|
|
continue
|
|
}
|
|
guard let diff = try? repository.diff(commit: commit) else { continue }
|
|
for delta in diff.changes where delta.type == .added || delta.type == .renamed || delta.type == .copied {
|
|
record(path: delta.newFile.path, rank: rank, into: &ranks)
|
|
}
|
|
}
|
|
return ranks
|
|
}
|
|
|
|
/// Records `path` and every directory prefix above it at `rank`, keeping the earliest rank any
|
|
/// of them has already earned.
|
|
private static func record(path: String, rank: Int, into ranks: inout [String: Int]) {
|
|
var components = path.split(separator: "/").map(String.init)
|
|
while !components.isEmpty {
|
|
let key = components.joined(separator: "/")
|
|
if let existing = ranks[key] {
|
|
ranks[key] = min(existing, rank)
|
|
} else {
|
|
ranks[key] = rank
|
|
}
|
|
components.removeLast()
|
|
}
|
|
}
|
|
|
|
// MARK: - Private helpers
|
|
|
|
/// Every blob path under `commit`'s tree, recursively.
|
|
private static func filePaths(of commit: Commit, in repository: Repository) -> [String] {
|
|
guard let tree = try? commit.tree else { return [] }
|
|
var paths: [String] = []
|
|
|
|
func walk(_ tree: Tree, prefix: String) {
|
|
for entry in tree.entries {
|
|
let path = prefix.isEmpty ? entry.name : prefix + "/" + entry.name
|
|
if entry.type == .tree {
|
|
guard let subtree: Tree = try? repository.show(id: entry.id) else { continue }
|
|
walk(subtree, prefix: path)
|
|
} else {
|
|
paths.append(path)
|
|
}
|
|
}
|
|
}
|
|
|
|
walk(tree, prefix: "")
|
|
return paths
|
|
}
|
|
|
|
/// The unborn HEAD's symbolic target, parsed out of `.git/HEAD`'s plain text
|
|
/// (`ref: refs/heads/main` → `main`).
|
|
private static func unbornBranchName(gitDirectory: URL) -> String? {
|
|
guard let contents = try? String(
|
|
contentsOf: gitDirectory.appendingPathComponent("HEAD"),
|
|
encoding: .utf8
|
|
) else { return nil }
|
|
let trimmed = contents.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
let prefix = "ref: refs/heads/"
|
|
guard trimmed.hasPrefix(prefix) else { return nil }
|
|
let name = String(trimmed.dropFirst(prefix.count))
|
|
return name.isEmpty ? nil : name
|
|
}
|
|
|
|
/// libgit2's own message for a SwiftGitX error — far more useful than the struct's synthesized
|
|
/// description — falling back to the description for anything else.
|
|
private static func reason(_ error: any Error) -> String {
|
|
if let gitError = error as? SwiftGitXError { return gitError.message }
|
|
return String(describing: error)
|
|
}
|
|
}
|