CommitMessageEngine replaces the interim composer as the wired default: a pure total function from two snapshots + changed paths to a message. Full vocabulary — Add / Delete / Move / Rename / Edit / Restyle / Resize / Reorder over cards, lanes, board; Attach / Remove; Repair for the duplicate remint (detected as a heal-classed rename-paired arrival whose id the previous snapshot never held — the loader withholds duplicates, so the shape is a bare arrival); the trash triple by diff shape alone (into .trash = Delete, out = Restore, leaving the tree = Permanently delete); Relabel / Assign / Set due date plus the named generic for custom keys. Plural folding with shared destinations, implied events as body bullets never subjects, ~40-char subject truncation, "(untitled)". Bookkeeping (sequence-preserving renumbers, stamps, backfilled kind) composes nothing. Non-snapshot paths compose path-shaped events — CLAUDE.md reads "Update agent guide (vN)" via the marker line (the m10 card's deferred bullet lands here), everything else "Update '<path>'". The comment verb family per 01's ruling (comments shipped, so 06 gains the verbs): Comment on / Edit comment on / Delete comment on / Draft comment on / Permanently delete comment on '<card>', grouped one event per comment folder, classified ahead of the model-silence rules, card title resolved from either snapshot. GIT_DELTA_ADDED is surfaced as GitChangedPath.isArrival — post vs edit is unanswerable from snapshots that exclude comments by ruling. A card moving with its thread swallows the comment events (implied-events one level down). The previous snapshot is HEAD's tree, materialized per flush into a temp dir (index.md blobs in full, other blobs zero-byte — the model reads attachment names, never bytes) and re-parsed through the one BoardLoader; never a value carried forward. changedPaths is a hard filter per split commit, which also earns the stage-around and kills phantom events. Launch catch-up and foreign windows compose through the same engine. 48 new tests (35 pure + comment family + engine-level); 2293 tests / 394 suites green; InertGitTests untouched. Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
694 lines
34 KiB
Swift
694 lines
34 KiB
Swift
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
|
|
|
|
/// Whether the path is **new in this commit** — git's own `GIT_DELTA_ADDED` (and a rename's
|
|
/// arriving end), surfaced rather than inferred.
|
|
///
|
|
/// It exists for the comment verb family (01-storage-format.md § Enhanced schema): comments are
|
|
/// window-scoped and the board snapshot never carries them, so "Comment on 'X'" and "Edit comment
|
|
/// on 'X'" cannot be told apart by a diff of two snapshots — the only thing that distinguishes a
|
|
/// comment folder arriving from one being rewritten is whether HEAD already had it, which is
|
|
/// exactly the question this diff already answered.
|
|
public let isArrival: Bool
|
|
|
|
public init(path: String, isDeletion: Bool, isRename: Bool, isArrival: Bool = false) {
|
|
self.path = path
|
|
self.isDeletion = isDeletion
|
|
self.isRename = isRename
|
|
self.isArrival = isArrival
|
|
}
|
|
}
|
|
|
|
// 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, isArrival: Bool = false) {
|
|
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,
|
|
// New wins, for the mirror of that reason: one delta calling a path an addition is
|
|
// enough to know HEAD did not have it, which is the whole content of the bit.
|
|
isArrival: (existing?.isArrival ?? false) || isArrival
|
|
)
|
|
}
|
|
|
|
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. The arriving end is new at
|
|
// its path, which is what the comment family reads a post by.
|
|
record(string(delta.old_file.path), isDeletion: true, isRename: true)
|
|
record(string(delta.new_file.path), isDeletion: false, isRename: true, isArrival: true)
|
|
case GIT_DELTA_ADDED, GIT_DELTA_COPIED, GIT_DELTA_UNTRACKED:
|
|
record(string(delta.new_file.path), isDeletion: false, isRename: false, isArrival: 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))
|
|
}
|
|
}
|