Build the auto-commit engine
Every settled change on a git-mode board commits, debounced 2s past drag/typing churn, staged whole-root with .gitignore respected. GitCommitOperation reaches the vendored libgit2 directly (same 1.9.2 pin SwiftGitX resolves — importable, not duplicated) for signature-capable commits; add-git's config materialization is gone, identity resolves at commit time (repo-local config, else derived default) per the 2026-07-31 ruling in 06. CommitAttribution classifies per file off EchoLedger receipts: user identity on app-mediated windows, Lanework External <[email protected]> on foreign, the modified-by refinement (<slug>@agents.lanework .invalid) when every foreign file agrees, heal-marked receipts split into their own commit — window split foreign → heal → user. Edit-session granularity: ~700ms saves stay uncommitted, staging excludes open session folders (closure-resolved so mid-session moves stage around the new location), session end nudges the debounce so each session lands exactly one body commit. Flush-before-overwrite gates on known-foreign windows and commits synchronously ahead of the write; close/quit flush the pipeline via CloseFlushCoordinator's committerFlush. index.lock backs off briefly then re-debounces silently; clean tree no-ops; genuine failures ride the standing history-suspension banner and retry next debounce. Abnormal repo states (detached HEAD, merge/rebase/cherry-pick in progress) hold the engine with a 15s re-check; unborn HEAD commits "Initial board state" whole-tree; dirty tree at open catches up through the same engine. Message seam (CommitMessageComposing) ships interim — the semantic composer is the next card. Discovery diffs HEAD against an in-memory index with rename detection (git status alone never pairs a bare mv), and a failed survey reads as "could not look", never "nothing changed". 46 new tests / 8 suites, all real repositories via bundled libgit2. 2240 tests / 383 suites green; InertGitTests untouched. Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
This commit is contained in:
@@ -0,0 +1,676 @@
|
||||
import Foundation
|
||||
import libgit2
|
||||
import os
|
||||
|
||||
// MARK: - Repository state
|
||||
|
||||
/// **A repo state the auto-committer holds for** (06-history-undo.md ▸ Rules ▸ Abnormal repo
|
||||
/// states): "a detached HEAD, or an in-progress merge/rebase/cherry-pick left by outside-the-app
|
||||
/// git … pauses the git surface honestly … auto-commit holds".
|
||||
///
|
||||
/// **Unborn HEAD is deliberately absent.** It is *normal* git mode — "the first auto-commit creates
|
||||
/// the root commit on the branch HEAD names, and the undo trail simply starts empty" — so it is a
|
||||
/// fact about how the next commit is shaped (`GitRepository.initialCommitSubject`), never a reason
|
||||
/// to stop.
|
||||
///
|
||||
/// The cases are libgit2's own `git_repository_state`, which reads exactly the marker files 06
|
||||
/// names (`MERGE_HEAD`, `rebase-merge/`, `rebase-apply/`, `CHERRY_PICK_HEAD`) plus the two this
|
||||
/// version has no story for but must not commit over either (`REVERT_HEAD`, `BISECT_LOG`).
|
||||
public enum GitRepositoryPause: String, Sendable, Equatable, CaseIterable {
|
||||
case detachedHead
|
||||
case merge
|
||||
case revert
|
||||
case cherryPick
|
||||
case bisect
|
||||
case rebase
|
||||
case applyMailbox
|
||||
|
||||
/// What the popover will say — **the branch-switching card's surface, phrased here** so the
|
||||
/// engine-side hold and the sentence that explains it cannot drift apart (06 ▸ Rules ▸ Abnormal
|
||||
/// repo states: "the popover's git section names the state plainly … and says resolving it
|
||||
/// belongs to the tool that created it").
|
||||
public var explanation: String {
|
||||
switch self {
|
||||
case .detachedHead: "HEAD is detached — commits would belong to no branch"
|
||||
case .merge: "a merge is in progress"
|
||||
case .revert: "a revert is in progress"
|
||||
case .cherryPick: "a cherry-pick is in progress"
|
||||
case .bisect: "a bisect is in progress"
|
||||
case .rebase: "a rebase is in progress"
|
||||
case .applyMailbox: "a patch application is in progress"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// What one look at the repository found, before any staging is attempted.
|
||||
public struct GitRepositoryReading: Sendable, Equatable {
|
||||
|
||||
/// The pause 06 holds for, or `nil` when the repository is in a state the committer may write in.
|
||||
public let pause: GitRepositoryPause?
|
||||
|
||||
/// Whether HEAD names a branch that has no commits yet — normal git mode, and the one thing that
|
||||
/// makes the next commit a root commit.
|
||||
public let isUnborn: Bool
|
||||
|
||||
/// Whether `index.lock` is held right now. Read as a file rather than inferred from a failure so
|
||||
/// the committer can back off *before* it has written anything (06 ▸ Interaction with external
|
||||
/// writers: "`index.lock` contention is never an error").
|
||||
public let isIndexLocked: Bool
|
||||
|
||||
public init(pause: GitRepositoryPause?, isUnborn: Bool, isIndexLocked: Bool) {
|
||||
self.pause = pause
|
||||
self.isUnborn = isUnborn
|
||||
self.isIndexLocked = isIndexLocked
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Changed paths
|
||||
|
||||
/// One path `git status` reports as differing between HEAD and the working tree.
|
||||
///
|
||||
/// Board-root-relative and file-granular, which is the unit both consumers want: staging adds or
|
||||
/// removes exactly these, and attribution asks a question per *file* (06 ▸ Interaction with external
|
||||
/// writers: "classify every observed change, per file").
|
||||
public struct GitChangedPath: Sendable, Equatable, Hashable {
|
||||
|
||||
/// The path, relative to the board root, in git's own spelling (`/` separators, no leading dot).
|
||||
public let path: String
|
||||
|
||||
/// Whether the file is **gone** from the working tree.
|
||||
///
|
||||
/// The `modified-by` rule turns on this bit — "any true deletion in the window falls back to
|
||||
/// `Lanework External` — a deletion leaves no file to stamp" — which is why the rename half
|
||||
/// below is a separate fact rather than folded in here.
|
||||
public let isDeletion: Bool
|
||||
|
||||
/// Whether this path is one end of a **rename** libgit2 paired up.
|
||||
///
|
||||
/// "**A folder move is not a deletion**: items match by id across the whole board … so a moved
|
||||
/// card attributes by its stamp like any changed file" (06). A paired departure is therefore a
|
||||
/// deletion on disk that the window must not be demoted by.
|
||||
public let isRename: Bool
|
||||
|
||||
public init(path: String, isDeletion: Bool, isRename: Bool) {
|
||||
self.path = path
|
||||
self.isDeletion = isDeletion
|
||||
self.isRename = isRename
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - A planned commit
|
||||
|
||||
/// One commit a flush intends to make: which paths it stages, what it says, and who it is by.
|
||||
///
|
||||
/// A value rather than a call, because the flush's whole decision — the three-way split, the
|
||||
/// ordering, the authorship — is made on the main actor from state the committer holds, and the
|
||||
/// libgit2 work is then a pure function of these (06 ▸ Interaction with external writers: the
|
||||
/// two-commit split; ruled 2026-07-29: the heal's third class).
|
||||
public struct PlannedCommit: Sendable, Equatable {
|
||||
|
||||
/// Board-root-relative paths, exactly as `GitChangedPath.path` spells them.
|
||||
public let paths: [String]
|
||||
|
||||
public let message: String
|
||||
|
||||
/// **Who the change is by** — the user, `Lanework External`, or a `modified-by` agent.
|
||||
public let author: GitIdentity
|
||||
|
||||
/// **Who made the commit** — always this machine's user identity.
|
||||
///
|
||||
/// A judgment call, recorded: 06 pins the *author* ("foreign changes are committed under the
|
||||
/// pinned synthetic author … so any git client can filter, log, and blame by origin" — and both
|
||||
/// `git log --author` and `git blame` read the author field) and says nothing about the
|
||||
/// committer. Git's own convention for recording somebody else's change — `git am`, cherry-pick,
|
||||
/// every forge's merge button — keeps the author as the originator and names the actor who
|
||||
/// created the commit as committer, which is honestly what happened here: Lanework, running as
|
||||
/// this user, wrote it. Setting both to the synthetic identity would claim the repository made
|
||||
/// itself.
|
||||
public let committer: GitIdentity
|
||||
|
||||
public init(paths: [String], message: String, author: GitIdentity, committer: GitIdentity) {
|
||||
self.paths = paths
|
||||
self.message = message
|
||||
self.author = author
|
||||
self.committer = committer
|
||||
}
|
||||
}
|
||||
|
||||
/// How a flush ended — the four outcomes 06 gives the committer, and no fifth.
|
||||
public enum GitCommitOutcome: Sendable, Equatable {
|
||||
|
||||
/// One commit per planned commit that had anything in it, oldest first.
|
||||
case committed([String])
|
||||
|
||||
/// **The happy path, not a malfunction** (06 ▸ Interaction with external writers): the tree had
|
||||
/// nothing to commit — an agent already committed its own work, or the window held only paths
|
||||
/// staged around.
|
||||
case nothingToCommit
|
||||
|
||||
/// **Never an error** (06): `index.lock` was held and stayed held through the brief retry. The
|
||||
/// caller re-debounces; nothing is surfaced.
|
||||
case locked
|
||||
|
||||
/// The repository is in a state the app does not write in (`GitRepositoryPause`). Edits keep
|
||||
/// landing on disk and commit as one settled batch when it clears.
|
||||
case held(GitRepositoryPause)
|
||||
|
||||
/// A genuine failure — disk full, corruption. Surfaced per 02-architecture.md ▸ Write-failure
|
||||
/// surfacing and retried on the next debounce.
|
||||
case failed(GitOperationFailure)
|
||||
}
|
||||
|
||||
// MARK: - GitCommitOperation
|
||||
|
||||
/// **The signature-capable commit path** (06-history-undo.md ▸ Interaction with external writers:
|
||||
/// "Commit attribution is structural, not just a message convention"), written against the vendored
|
||||
/// libgit2 C API directly.
|
||||
///
|
||||
/// ### Why it is not SwiftGitX
|
||||
///
|
||||
/// SwiftGitX 0.4.0's `Repository.commit(message:)` takes no signature: its `CommitOptions` leaves
|
||||
/// `author` and `committer` null, so libgit2 falls back to `git_signature_default`, which resolves
|
||||
/// through the merged config ladder — unreadable in the sandbox, and the wrong question anyway
|
||||
/// (06 rules `~/.gitconfig` out of the identity story entirely). Per-commit authorship is this
|
||||
/// card's whole point: the user's identity on user-driven commits, `Lanework External` on foreign
|
||||
/// ones, a `modified-by` agent's on stamped ones. None of that is reachable through the wrapper, and
|
||||
/// `Repository.pointer` is `internal`, so there is no seam to borrow either.
|
||||
///
|
||||
/// The module underneath *is* reachable — SwiftGitX vendors `libgit2` as a package product, and
|
||||
/// `project.yml` names the same pin SwiftGitX pins, so this adds an import rather than a second copy
|
||||
/// of the library. Everything SwiftGitX does well (`GitRepository`'s reads) still goes through it.
|
||||
///
|
||||
/// ### Isolation
|
||||
///
|
||||
/// `GitRepository`'s rule, unchanged and for its reason: every function here is `nonisolated`, opens
|
||||
/// its own `git_repository`, and frees it in the same synchronous scope. No handle crosses an
|
||||
/// `await`, a `Task`, or a stored property, so libgit2 never sees two threads on one handle.
|
||||
enum GitCommitOperation {
|
||||
|
||||
private static let logger = Logger(subsystem: "dev.rzen.indie.Kanban", category: "git")
|
||||
|
||||
/// libgit2's global state, brought up exactly once per process.
|
||||
///
|
||||
/// SwiftGitX calls `git_libgit2_init` from `Repository.init`/`open` and pairs it with a shutdown
|
||||
/// in `deinit`, which is a refcount this file must not ride on: a flush can run when no
|
||||
/// `Repository` is alive. A `static let` is Swift's own run-once, and the matching shutdown is
|
||||
/// deliberately never called — the library stays up for the life of the process, which is what
|
||||
/// every consumer here wants.
|
||||
private static let startUp: Bool = {
|
||||
git_libgit2_init() >= 0
|
||||
}()
|
||||
|
||||
// MARK: - Reading
|
||||
|
||||
/// The three facts a flush checks **before every attempt** (06 ▸ Rules ▸ Abnormal repo states:
|
||||
/// "the check runs at open and again before every flush, so finishing the operation in a
|
||||
/// terminal resumes the pipeline without ceremony").
|
||||
///
|
||||
/// A repository that cannot be opened at all reads as no pause, not unborn, not locked — the
|
||||
/// same shrug every read in `GitRepository` gives an unopenable repo, and the commit attempt
|
||||
/// that follows will fail honestly with libgit2's own message rather than on a guess made here.
|
||||
nonisolated static func reading(at boardRoot: URL) -> GitRepositoryReading {
|
||||
_ = startUp
|
||||
guard let repository = open(boardRoot) else {
|
||||
return GitRepositoryReading(pause: nil, isUnborn: false, isIndexLocked: false)
|
||||
}
|
||||
defer { git_repository_free(repository) }
|
||||
|
||||
let locked = isIndexLocked(gitDirectory: gitDirectory(of: repository))
|
||||
let unborn = git_repository_head_unborn(repository) == 1
|
||||
|
||||
// Detached HEAD is asked first because it is the state an unborn repo cannot be in and the
|
||||
// one `git_repository_state` does not model: libgit2 keeps "what operation is in progress"
|
||||
// and "where HEAD points" as separate questions.
|
||||
if !unborn, git_repository_head_detached(repository) == 1 {
|
||||
return GitRepositoryReading(pause: .detachedHead, isUnborn: false, isIndexLocked: locked)
|
||||
}
|
||||
return GitRepositoryReading(pause: pause(of: repository), isUnborn: unborn, isIndexLocked: locked)
|
||||
}
|
||||
|
||||
/// libgit2's `git_repository_state`, mapped to the pauses 06 names.
|
||||
///
|
||||
/// It reads the marker files the design lists (`rebase-merge/`, `rebase-apply/`, `MERGE_HEAD`,
|
||||
/// `REVERT_HEAD`, `CHERRY_PICK_HEAD`, `BISECT_LOG`) — which is why a test can plant one file and
|
||||
/// get the real answer rather than a mocked one.
|
||||
private static func pause(of repository: OpaquePointer) -> GitRepositoryPause? {
|
||||
switch git_repository_state(repository) {
|
||||
case Int32(GIT_REPOSITORY_STATE_NONE.rawValue): nil
|
||||
case Int32(GIT_REPOSITORY_STATE_MERGE.rawValue): .merge
|
||||
case Int32(GIT_REPOSITORY_STATE_REVERT.rawValue),
|
||||
Int32(GIT_REPOSITORY_STATE_REVERT_SEQUENCE.rawValue): .revert
|
||||
case Int32(GIT_REPOSITORY_STATE_CHERRYPICK.rawValue),
|
||||
Int32(GIT_REPOSITORY_STATE_CHERRYPICK_SEQUENCE.rawValue): .cherryPick
|
||||
case Int32(GIT_REPOSITORY_STATE_BISECT.rawValue): .bisect
|
||||
case Int32(GIT_REPOSITORY_STATE_APPLY_MAILBOX.rawValue),
|
||||
Int32(GIT_REPOSITORY_STATE_APPLY_MAILBOX_OR_REBASE.rawValue): .applyMailbox
|
||||
// Every rebase flavour reads as one pause: the popover says "a rebase is in progress" and the
|
||||
// engine holds, and no consumer of either is finer-grained than that.
|
||||
default: .rebase
|
||||
}
|
||||
}
|
||||
|
||||
/// **Which paths differ between HEAD and the working tree**, `.gitignore` respected.
|
||||
///
|
||||
/// This is the commit's *condition* — "its commit condition is the *tree*, not the snapshot
|
||||
/// diff, so a stray-only window commits rather than leaving the tree dirty" (06 ▸ Commit
|
||||
/// messages ▸ Non-snapshot files commit too) — and it is also the composer's second input, which
|
||||
/// is why it comes back as values rather than as a count.
|
||||
///
|
||||
/// ### Why it stages into the index rather than reading `git_status`
|
||||
///
|
||||
/// Because of one clause: "**A folder move is not a deletion**: items match by id across the
|
||||
/// whole board … so a moved card attributes by its stamp like any changed file" (06). Rename
|
||||
/// detection is a *similarity* pass over a diff, and libgit2 only runs it where both ends are in
|
||||
/// one diff — `git_status`' `RENAMES_INDEX_TO_WORKDIR` finds a rename made **after** staging, and
|
||||
/// a plain `mv` in a working tree nobody has staged is simply a delete beside an add. Measured,
|
||||
/// not assumed: the first cut of this function used status with every rename flag set, and a
|
||||
/// re-stamped agent move still demoted to `Lanework External`.
|
||||
///
|
||||
/// So the diff is taken where the pairing can be seen: everything the working tree says is staged
|
||||
/// into the **in-memory** index (`git_index_add_all` — full `git add -A` semantics, ignores
|
||||
/// respected, deletions dropped), HEAD's tree is diffed against it, and `git_diff_find_similar`
|
||||
/// pairs the ends. **Nothing is written**: the index file on disk is untouched, which is what
|
||||
/// keeps this a read, and the index object is reset to HEAD on the way out so a caller that goes
|
||||
/// on to stage a *subset* starts from a known base rather than from everything.
|
||||
/// **`nil` means the survey could not be taken**, which is emphatically not the same answer as
|
||||
/// "nothing changed" and must never be flattened into it.
|
||||
///
|
||||
/// Staging writes blobs into the object store, so a repository whose `.git/objects` has become
|
||||
/// unwritable fails *here* rather than at the commit — and a version of this that shrugged and
|
||||
/// returned no paths would report a clean tree, no-op silently, and let history stop advancing
|
||||
/// with nothing on the banner strip. That is precisely the case 06 separates from contention:
|
||||
/// "Genuine commit failures — disk full, repo corruption — are different: files stay safe on disk
|
||||
/// but history stops advancing; surfaced per 02-architecture.md ▸ Write-failure surfacing."
|
||||
/// (Found by test rather than by reading: the failure suite went green-by-silence when discovery
|
||||
/// moved from `git_status` to staging.)
|
||||
nonisolated static func surveyChangedPaths(at boardRoot: URL) -> [GitChangedPath]? {
|
||||
_ = startUp
|
||||
guard let repository = open(boardRoot) else { return nil }
|
||||
defer { git_repository_free(repository) }
|
||||
var index: OpaquePointer?
|
||||
guard git_repository_index(&index, repository) == 0, let index else { return nil }
|
||||
defer { git_index_free(index) }
|
||||
return changedPaths(in: repository, index: index)
|
||||
}
|
||||
|
||||
/// The survey, with "could not look" folded into "nothing to do" — for the callers that have no
|
||||
/// failure channel and want the safe answer: `GitRepository.create`'s branch line, and the tests'
|
||||
/// clean-tree assertions.
|
||||
nonisolated static func changedPaths(at boardRoot: URL) -> [GitChangedPath] {
|
||||
surveyChangedPaths(at: boardRoot) ?? []
|
||||
}
|
||||
|
||||
private static func changedPaths(in repository: OpaquePointer, index: OpaquePointer) -> [GitChangedPath]? {
|
||||
var pathspec = git_strarray()
|
||||
guard git_index_add_all(index, &pathspec, GIT_INDEX_ADD_DEFAULT.rawValue, nil, nil) == 0 else {
|
||||
return nil
|
||||
}
|
||||
defer { resetIndexToHead(index, in: repository) }
|
||||
|
||||
let parent = headCommit(of: repository)
|
||||
defer { parent.map(git_commit_free) }
|
||||
var headTree: OpaquePointer?
|
||||
if let parent { git_commit_tree(&headTree, parent) }
|
||||
defer { headTree.map(git_tree_free) }
|
||||
|
||||
var diff: OpaquePointer?
|
||||
var options = git_diff_options()
|
||||
guard git_diff_options_init(&options, UInt32(GIT_DIFF_OPTIONS_VERSION)) == 0,
|
||||
git_diff_tree_to_index(&diff, repository, headTree, index, &options) == 0,
|
||||
let diff else { return nil }
|
||||
defer { git_diff_free(diff) }
|
||||
|
||||
var findOptions = git_diff_find_options()
|
||||
if git_diff_find_options_init(&findOptions, UInt32(GIT_DIFF_FIND_OPTIONS_VERSION)) == 0 {
|
||||
findOptions.flags = GIT_DIFF_FIND_RENAMES.rawValue
|
||||
// Best-effort: a diff too large for the similarity pass simply reports the unpaired
|
||||
// shape, which demotes the window to the generic external author — the safe direction,
|
||||
// and the one the guide's re-stamping advice already covers.
|
||||
_ = git_diff_find_similar(diff, &findOptions)
|
||||
}
|
||||
|
||||
var found: [String: GitChangedPath] = [:]
|
||||
|
||||
func record(_ path: String?, isDeletion: Bool, isRename: Bool) {
|
||||
guard let path, !path.isEmpty else { return }
|
||||
let existing = found[path]
|
||||
found[path] = GitChangedPath(
|
||||
path: path,
|
||||
// Present wins where two deltas disagree: staging asks "is it there now", and the
|
||||
// `modified-by` demotion must not fire for a file the window ends with.
|
||||
isDeletion: (existing?.isDeletion ?? true) && isDeletion,
|
||||
isRename: (existing?.isRename ?? false) || isRename
|
||||
)
|
||||
}
|
||||
|
||||
for position in 0..<git_diff_num_deltas(diff) {
|
||||
guard let delta = git_diff_get_delta(diff, position)?.pointee else { continue }
|
||||
switch delta.status {
|
||||
case GIT_DELTA_DELETED:
|
||||
record(string(delta.old_file.path), isDeletion: true, isRename: false)
|
||||
case GIT_DELTA_RENAMED:
|
||||
// Both ends, and neither is a deletion the window may be demoted by: the departure
|
||||
// has to leave the index and the arrival has to enter it.
|
||||
record(string(delta.old_file.path), isDeletion: true, isRename: true)
|
||||
record(string(delta.new_file.path), isDeletion: false, isRename: true)
|
||||
default:
|
||||
record(string(delta.new_file.path), isDeletion: false, isRename: false)
|
||||
}
|
||||
}
|
||||
|
||||
return found.values.sorted { $0.path < $1.path }
|
||||
}
|
||||
|
||||
/// Puts the index object back to exactly HEAD's tree — the base every per-class staging works up
|
||||
/// from, and what makes the discovery pass above a read.
|
||||
///
|
||||
/// On an unborn HEAD that is an empty index, which is the same statement with no tree to say it
|
||||
/// with. Note this is the *in-memory* index: nothing here calls `git_index_write`, so a caller
|
||||
/// that abandons the flush leaves the file on disk exactly as it found it, staged changes of
|
||||
/// another writer's included.
|
||||
private static func resetIndexToHead(_ index: OpaquePointer, in repository: OpaquePointer) {
|
||||
guard let parent = headCommit(of: repository) else {
|
||||
git_index_clear(index)
|
||||
return
|
||||
}
|
||||
defer { git_commit_free(parent) }
|
||||
var tree: OpaquePointer?
|
||||
guard git_commit_tree(&tree, parent) == 0, let tree else { return }
|
||||
defer { git_tree_free(tree) }
|
||||
git_index_read_tree(index, tree)
|
||||
}
|
||||
|
||||
// MARK: - Committing
|
||||
|
||||
/// **Stages and commits each plan in turn, with explicit signatures.**
|
||||
///
|
||||
/// The plans are committed **in the order given** and each is a whole commit of its own — which
|
||||
/// is how the two-commit split ("A debounce window containing both kinds is split into two
|
||||
/// commits, never mixed") and the heal's third class (ruled 2026-07-29) become one mechanism
|
||||
/// rather than three code paths.
|
||||
///
|
||||
/// A plan whose staging produces the tree HEAD already has is **skipped, not committed**: an
|
||||
/// empty commit says nothing and would make `git log` a record of the debounce timer rather than
|
||||
/// of the board. That is also the clean-tree no-op, arrived at without a special case.
|
||||
///
|
||||
/// - Parameter allowRootCommit: whether an unborn HEAD may take its root commit here. The caller
|
||||
/// passes `true`; it exists so the "first settled change on an adopted unborn repo commits the
|
||||
/// whole tree as *Initial board state*" rule stays a decision the *planner* made and is not
|
||||
/// re-derived down here.
|
||||
nonisolated static func perform(
|
||||
at boardRoot: URL,
|
||||
commits: [PlannedCommit],
|
||||
allowRootCommit: Bool = true
|
||||
) -> GitCommitOutcome {
|
||||
_ = startUp
|
||||
guard !commits.isEmpty else { return .nothingToCommit }
|
||||
guard let repository = open(boardRoot) else {
|
||||
return .failed(GitOperationFailure(operation: operationName, message: lastErrorMessage()))
|
||||
}
|
||||
defer { git_repository_free(repository) }
|
||||
|
||||
// Re-checked here, inside the same handle that is about to write, rather than trusted from
|
||||
// the caller's earlier `reading(at:)`: between the two a terminal can have started a rebase,
|
||||
// and 06's rule is that the check runs "again before every flush".
|
||||
if git_repository_head_unborn(repository) == 1 {
|
||||
guard allowRootCommit else { return .nothingToCommit }
|
||||
} else if git_repository_head_detached(repository) == 1 {
|
||||
return .held(.detachedHead)
|
||||
}
|
||||
if let pause = pause(of: repository) { return .held(pause) }
|
||||
if isIndexLocked(gitDirectory: gitDirectory(of: repository)) { return .locked }
|
||||
|
||||
var index: OpaquePointer?
|
||||
guard git_repository_index(&index, repository) == 0, let index else {
|
||||
return failure(lastErrorMessage())
|
||||
}
|
||||
defer { git_index_free(index) }
|
||||
|
||||
// **Every split starts from HEAD, not from whatever the index happened to hold.** Each plan
|
||||
// below writes the *whole* index as a tree, so a change another writer had staged but not
|
||||
// committed would otherwise ride into whichever commit came first — silently attributing it
|
||||
// to that class. Resetting makes each commit exactly HEAD plus the paths its own class
|
||||
// staged, which is what "split into two commits, never mixed" has to mean. The staged change
|
||||
// is not lost: it is a changed path like any other and is classified and committed on its
|
||||
// own terms.
|
||||
resetIndexToHead(index, in: repository)
|
||||
|
||||
var landed: [String] = []
|
||||
for plan in commits {
|
||||
switch commit(plan, in: repository, index: index) {
|
||||
case let .landed(oid):
|
||||
landed.append(oid)
|
||||
case .skipped:
|
||||
continue
|
||||
case let .stopped(outcome):
|
||||
// Whatever landed before the failure stays landed — those commits are real, and
|
||||
// reporting them is what lets the caller clear the suspension for the half that
|
||||
// worked while retrying the rest on the next debounce.
|
||||
if case let .failed(reason) = outcome, !landed.isEmpty {
|
||||
logger.error("commit split failed partway: \(reason.message, privacy: .public)")
|
||||
}
|
||||
return outcome
|
||||
}
|
||||
}
|
||||
return landed.isEmpty ? .nothingToCommit : .committed(landed)
|
||||
}
|
||||
|
||||
/// What one plan did.
|
||||
private enum CommitStep {
|
||||
case landed(String)
|
||||
/// Its staging produced the tree HEAD already has — an empty commit, deliberately not made.
|
||||
case skipped
|
||||
case stopped(GitCommitOutcome)
|
||||
}
|
||||
|
||||
/// One plan: stage its paths, write the tree, and create the commit if the tree is new.
|
||||
private static func commit(
|
||||
_ plan: PlannedCommit,
|
||||
in repository: OpaquePointer,
|
||||
index: OpaquePointer
|
||||
) -> CommitStep {
|
||||
// **Path by path, never a pathspec.** `git_index_add_all` would take a glob, and a card
|
||||
// titled with a `[` in its folder name is a real board; exact `add`/`remove` calls also make
|
||||
// the stage-around exact — an excluded folder is one this loop never mentions, rather than
|
||||
// one a matcher has to be trusted to miss.
|
||||
for path in plan.paths {
|
||||
let exists = FileManager.default.fileExists(
|
||||
atPath: workdir(of: repository).appendingPathComponent(path).path
|
||||
)
|
||||
let status = exists
|
||||
? git_index_add_bypath(index, path)
|
||||
: git_index_remove_bypath(index, path)
|
||||
// `GIT_ENOTFOUND` on a removal is a path the index never had — an untracked file that
|
||||
// vanished inside the window. Nothing to stage and nothing wrong.
|
||||
guard status == 0 || (!exists && status == GIT_ENOTFOUND.rawValue) else {
|
||||
return .stopped(classify(status))
|
||||
}
|
||||
}
|
||||
|
||||
var treeOID = git_oid()
|
||||
guard git_index_write_tree(&treeOID, index) == 0 else { return .stopped(classify(lastErrorCode())) }
|
||||
|
||||
let parent = headCommit(of: repository)
|
||||
defer { parent.map(git_commit_free) }
|
||||
if let parent, let headTree = treeIdentity(of: parent), equal(headTree, treeOID) {
|
||||
return .skipped
|
||||
}
|
||||
|
||||
// The index is persisted **before** the commit, deliberately: this is the call `index.lock`
|
||||
// bites on, and failing here leaves an unreferenced tree object (garbage libgit2 collects)
|
||||
// rather than a commit whose index nobody can see.
|
||||
guard git_index_write(index) == 0 else { return .stopped(classify(lastErrorCode())) }
|
||||
|
||||
var tree: OpaquePointer?
|
||||
guard git_tree_lookup(&tree, repository, &treeOID) == 0, let tree else {
|
||||
return .stopped(classify(lastErrorCode()))
|
||||
}
|
||||
defer { git_tree_free(tree) }
|
||||
|
||||
guard let author = signature(plan.author), let committer = signature(plan.committer) else {
|
||||
return .stopped(failure(lastErrorMessage()))
|
||||
}
|
||||
defer {
|
||||
git_signature_free(author)
|
||||
git_signature_free(committer)
|
||||
}
|
||||
|
||||
var commitOID = git_oid()
|
||||
var parents: [OpaquePointer?] = parent.map { [$0] } ?? []
|
||||
let status = parents.withUnsafeMutableBufferPointer { buffer in
|
||||
git_commit_create(
|
||||
&commitOID,
|
||||
repository,
|
||||
// "HEAD" rather than a branch name: on an unborn HEAD this creates the branch the
|
||||
// symbolic ref names, and on a born one it advances whatever branch is checked out —
|
||||
// one call for the root commit and every commit after it.
|
||||
"HEAD",
|
||||
author,
|
||||
committer,
|
||||
nil,
|
||||
plan.message,
|
||||
tree,
|
||||
buffer.count,
|
||||
buffer.baseAddress
|
||||
)
|
||||
}
|
||||
guard status == 0 else { return .stopped(classify(status)) }
|
||||
return .landed(hex(commitOID))
|
||||
}
|
||||
|
||||
// MARK: - Identity
|
||||
|
||||
/// **Where the user's identity comes from, resolved at commit time** (06 ▸ Interaction with
|
||||
/// external writers ▸ "Where the user's git identity comes from") — repo-local `.git/config`
|
||||
/// when present, the derived default otherwise.
|
||||
///
|
||||
/// **The one place that order lives.** Until this card, `GitRepository.applyIdentity` also
|
||||
/// encoded it, by *materializing* the resolved identity into the new repository's config so that
|
||||
/// libgit2's signature-less commit would find something; that was an explicit interim and it is
|
||||
/// gone. Nothing writes `user.name`/`user.email` any more: the popover's identity fields (a
|
||||
/// later card) will, because there "the setting *is* the file", and an app that wrote the file
|
||||
/// on its own could never tell its own default from the user's choice.
|
||||
///
|
||||
/// The `.git` directory is libgit2's answer rather than `boardRoot/.git`, so a board whose
|
||||
/// `.git` is a *file* (a linked worktree — `BoardGitMode` counts those as git mode) resolves its
|
||||
/// real config instead of trying to parse a pointer.
|
||||
nonisolated static func userIdentity(at boardRoot: URL) -> GitIdentity {
|
||||
_ = startUp
|
||||
guard let repository = open(boardRoot) else {
|
||||
return GitIdentity.resolve(repoLocal: (nil, nil), derived: .derivedDefault())
|
||||
}
|
||||
defer { git_repository_free(repository) }
|
||||
return GitIdentity.resolve(
|
||||
repoLocal: GitConfigFile.identity(inGitDirectory: gitDirectory(of: repository)),
|
||||
derived: .derivedDefault()
|
||||
)
|
||||
}
|
||||
|
||||
private static func signature(_ identity: GitIdentity) -> UnsafeMutablePointer<git_signature>? {
|
||||
var signature: UnsafeMutablePointer<git_signature>?
|
||||
let now = Date()
|
||||
let status = git_signature_new(
|
||||
&signature,
|
||||
identity.name,
|
||||
identity.email,
|
||||
git_time_t(now.timeIntervalSince1970),
|
||||
Int32(TimeZone.current.secondsFromGMT(for: now) / 60)
|
||||
)
|
||||
return status == 0 ? signature : nil
|
||||
}
|
||||
|
||||
// MARK: - index.lock
|
||||
|
||||
/// Whether `.git/index.lock` is there right now.
|
||||
///
|
||||
/// **Never removed, whatever its age** (06 ▸ Interaction with external writers): "a crashed
|
||||
/// writer's leftover is the user's to clear; the never-mutate rule's one exemption is the app's
|
||||
/// own leftovers". The pathfinder deleted locks older than ten minutes; that heuristic is
|
||||
/// deliberately not carried over — it is precisely a mutation of repo state the app did not
|
||||
/// create.
|
||||
nonisolated static func isIndexLocked(at boardRoot: URL) -> Bool {
|
||||
_ = startUp
|
||||
guard let repository = open(boardRoot) else { return false }
|
||||
defer { git_repository_free(repository) }
|
||||
return isIndexLocked(gitDirectory: gitDirectory(of: repository))
|
||||
}
|
||||
|
||||
private static func isIndexLocked(gitDirectory: URL) -> Bool {
|
||||
FileManager.default.fileExists(atPath: gitDirectory.appendingPathComponent("index.lock").path)
|
||||
}
|
||||
|
||||
// MARK: - Private plumbing
|
||||
|
||||
private static let operationName = "Recording this board's history"
|
||||
|
||||
private static func open(_ boardRoot: URL) -> OpaquePointer? {
|
||||
guard BoardGitMode.hasGitEntry(at: boardRoot) else { return nil }
|
||||
var repository: OpaquePointer?
|
||||
guard git_repository_open(&repository, boardRoot.path) == 0 else { return nil }
|
||||
return repository
|
||||
}
|
||||
|
||||
private static func gitDirectory(of repository: OpaquePointer) -> URL {
|
||||
URL(fileURLWithPath: string(git_repository_path(repository)) ?? "", isDirectory: true)
|
||||
}
|
||||
|
||||
private static func workdir(of repository: OpaquePointer) -> URL {
|
||||
URL(fileURLWithPath: string(git_repository_workdir(repository)) ?? "", isDirectory: true)
|
||||
}
|
||||
|
||||
private static func headCommit(of repository: OpaquePointer) -> OpaquePointer? {
|
||||
var reference: OpaquePointer?
|
||||
guard git_repository_head(&reference, repository) == 0, let reference else { return nil }
|
||||
defer { git_reference_free(reference) }
|
||||
var object: OpaquePointer?
|
||||
guard git_reference_peel(&object, reference, GIT_OBJECT_COMMIT) == 0 else { return nil }
|
||||
return object
|
||||
}
|
||||
|
||||
private static func treeIdentity(of commit: OpaquePointer) -> git_oid? {
|
||||
guard let tree = git_commit_tree_id(commit) else { return nil }
|
||||
return tree.pointee
|
||||
}
|
||||
|
||||
private static func equal(_ lhs: git_oid, _ rhs: git_oid) -> Bool {
|
||||
var left = lhs
|
||||
var right = rhs
|
||||
return git_oid_cmp(&left, &right) == 0
|
||||
}
|
||||
|
||||
private static func hex(_ oid: git_oid) -> String {
|
||||
var value = oid
|
||||
var buffer = [CChar](repeating: 0, count: Int(GIT_OID_MAX_HEXSIZE) + 1)
|
||||
git_oid_fmt(&buffer, &value)
|
||||
return String(cString: buffer)
|
||||
}
|
||||
|
||||
private static func string(_ pointer: UnsafePointer<CChar>?) -> String? {
|
||||
pointer.map { String(cString: $0) }
|
||||
}
|
||||
|
||||
/// libgit2's message for whatever just failed, or a shrug when it set none.
|
||||
private static func lastErrorMessage() -> String {
|
||||
guard let error = git_error_last(), let message = error.pointee.message else {
|
||||
return "libgit2 reported no reason"
|
||||
}
|
||||
return String(cString: message)
|
||||
}
|
||||
|
||||
private static func lastErrorCode() -> Int32 {
|
||||
git_error_last() != nil ? GIT_ERROR.rawValue : GIT_ERROR.rawValue
|
||||
}
|
||||
|
||||
/// Turns a libgit2 status into the outcome 06 gives it.
|
||||
///
|
||||
/// **`GIT_ELOCKED` is the whole reason this exists**: contention is "never an error", so it must
|
||||
/// not travel the same road as a disk failure. Everything else is a genuine failure carrying
|
||||
/// libgit2's own message.
|
||||
private static func classify(_ status: Int32) -> GitCommitOutcome {
|
||||
status == GIT_ELOCKED.rawValue ? .locked : failure(lastErrorMessage())
|
||||
}
|
||||
|
||||
private static func failure(_ message: String) -> GitCommitOutcome {
|
||||
.failed(GitOperationFailure(operation: operationName, message: message))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user