Build the semantic commit-message engine
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
This commit is contained in:
@@ -25,13 +25,18 @@ public enum CommitAuthorship: Sendable, Equatable {
|
|||||||
/// Everything the message engine is handed for one commit.
|
/// Everything the message engine is handed for one commit.
|
||||||
///
|
///
|
||||||
/// **A struct rather than an argument list**, because the point of this seam is that the semantic
|
/// **A struct rather than an argument list**, because the point of this seam is that the semantic
|
||||||
/// composer — the next card — plugs into it without reshaping the engine: it can start reading
|
/// composer plugs into it without reshaping the engine: any input it turns out to need joins this
|
||||||
/// `snapshot` and HEAD's tree the day it lands, and any input it turns out to need joins this type
|
/// type rather than every call site. It did need two — `previousSnapshot` and `agentGuideText`, both
|
||||||
/// rather than every call site.
|
/// below — and that is exactly what this shape was for.
|
||||||
|
///
|
||||||
|
/// **Everything here is a value, and that is the design.** The composer (`CommitMessageEngine`) reads
|
||||||
|
/// no file and opens no repository: the flush resolves the environment once — the board as HEAD has
|
||||||
|
/// it, the board as the app has it, the guide's bytes — and the message is then a pure function of
|
||||||
|
/// this struct. Resolving once per *flush* rather than once per planned commit also means a
|
||||||
|
/// three-way-split window materializes HEAD's tree once, not three times.
|
||||||
public struct CommitMessageRequest: Sendable {
|
public struct CommitMessageRequest: Sendable {
|
||||||
|
|
||||||
/// The board this is a commit in — and, for the composer card, where HEAD's tree is read from
|
/// The board this is a commit in.
|
||||||
/// for the last-committed half of its diff.
|
|
||||||
public let boardRoot: URL
|
public let boardRoot: URL
|
||||||
|
|
||||||
/// **The changed-path list** (06 ▸ Commit messages ▸ Non-snapshot files commit too: "beside the
|
/// **The changed-path list** (06 ▸ Commit messages ▸ Non-snapshot files commit too: "beside the
|
||||||
@@ -46,23 +51,46 @@ public struct CommitMessageRequest: Sendable {
|
|||||||
/// ("Initial board state", 06 ▸ Rules ▸ Abnormal repo states).
|
/// ("Initial board state", 06 ▸ Rules ▸ Abnormal repo states).
|
||||||
public let isRootCommit: Bool
|
public let isRootCommit: Bool
|
||||||
|
|
||||||
/// The board as the app last read it, or `nil` where no store is attached (a storeless
|
/// **The current half** of the composer's "last-committed vs. current" diff — the board as the
|
||||||
/// committer, a test). The current half of the composer's "last-committed vs. current" diff; the
|
/// app last read it, or, where no store is attached, as the flush read it off disk itself.
|
||||||
/// other half is HEAD's tree, which the composer reads for itself.
|
///
|
||||||
|
/// `nil` only when neither could answer: a board whose working tree does not load at all, which
|
||||||
|
/// is a commit that will have to be described by its paths.
|
||||||
public let snapshot: BoardModel?
|
public let snapshot: BoardModel?
|
||||||
|
|
||||||
|
/// **The last-committed half**: the board as HEAD's tree has it (`GitHeadSnapshot`).
|
||||||
|
///
|
||||||
|
/// `nil` on an unborn HEAD — where `isRootCommit` already says everything — and on a HEAD whose
|
||||||
|
/// tree does not load as a board. It is read from the repository rather than carried forward from
|
||||||
|
/// the last commit the app made, because the app is not the only writer and because launch
|
||||||
|
/// catch-up has no carried value to offer: see `GitHeadSnapshot` for the whole of that argument.
|
||||||
|
public let previousSnapshot: BoardModel?
|
||||||
|
|
||||||
|
/// **The board-root `CLAUDE.md` as it now reads**, when this commit touches it — the one
|
||||||
|
/// non-snapshot file with a subject of its own ("Update agent guide (vN)", 06 ▸ Commit messages).
|
||||||
|
///
|
||||||
|
/// The *text*, not the version: N is "a pure function of file content"
|
||||||
|
/// (`AgentGuide.installedVersion`), and keeping the parse on the composer's side is what keeps
|
||||||
|
/// that rule where the message vocabulary is. `nil` when the guide is not in this commit, cannot
|
||||||
|
/// be read, or has been deleted.
|
||||||
|
public let agentGuideText: String?
|
||||||
|
|
||||||
public init(
|
public init(
|
||||||
boardRoot: URL,
|
boardRoot: URL,
|
||||||
changedPaths: [GitChangedPath],
|
changedPaths: [GitChangedPath],
|
||||||
authorship: CommitAuthorship,
|
authorship: CommitAuthorship,
|
||||||
isRootCommit: Bool,
|
isRootCommit: Bool,
|
||||||
snapshot: BoardModel?
|
snapshot: BoardModel?,
|
||||||
|
previousSnapshot: BoardModel? = nil,
|
||||||
|
agentGuideText: String? = nil
|
||||||
) {
|
) {
|
||||||
self.boardRoot = boardRoot
|
self.boardRoot = boardRoot
|
||||||
self.changedPaths = changedPaths
|
self.changedPaths = changedPaths
|
||||||
self.authorship = authorship
|
self.authorship = authorship
|
||||||
self.isRootCommit = isRootCommit
|
self.isRootCommit = isRootCommit
|
||||||
self.snapshot = snapshot
|
self.snapshot = snapshot
|
||||||
|
self.previousSnapshot = previousSnapshot
|
||||||
|
self.agentGuideText = agentGuideText
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -70,11 +98,11 @@ public struct CommitMessageRequest: Sendable {
|
|||||||
|
|
||||||
/// **What a commit says** (06-history-undo.md ▸ Commit messages).
|
/// **What a commit says** (06-history-undo.md ▸ Commit messages).
|
||||||
///
|
///
|
||||||
/// The real implementation is the next card's: "a pure, testable function" composing from a
|
/// The implementation is `SemanticCommitMessage` below, over `CommitMessageEngine`: "a pure, testable
|
||||||
/// structural diff of two board snapshots, with the whole Add/Delete/Move/Rename/Edit vocabulary,
|
/// function" composing from a structural diff of two board snapshots, with the whole
|
||||||
/// plural folding, path-shaped events for non-snapshot files, and the trash pair. None of that
|
/// Add/Delete/Move/Rename/Edit vocabulary, plural folding, path-shaped events for non-snapshot files,
|
||||||
/// exists yet; what exists is this protocol, so that arriving card is one type conforming here
|
/// and the trash pair. The protocol survives its interim purpose because it is still what lets a test
|
||||||
/// rather than a change to the engine that calls it.
|
/// inject a fake and assert *when* a message was asked for without asserting what it said.
|
||||||
///
|
///
|
||||||
/// `Sendable` because composition runs off the main actor, inside the same detached task that stages
|
/// `Sendable` because composition runs off the main actor, inside the same detached task that stages
|
||||||
/// and commits — the message has to be in hand before `git_commit_create` is called, and none of the
|
/// and commits — the message has to be in hand before `git_commit_create` is called, and none of the
|
||||||
@@ -83,22 +111,19 @@ public protocol CommitMessageComposing: Sendable {
|
|||||||
func message(for request: CommitMessageRequest) -> String
|
func message(for request: CommitMessageRequest) -> String
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - The interim
|
// MARK: - The wired composer
|
||||||
|
|
||||||
/// **The placeholder message**, deliberately the *fallback* 06 already names rather than an
|
/// **The semantic composer**, and the committer's default (`GitAutoCommitter.composer`).
|
||||||
/// invention: "genuinely mixed windows fall back to 'Update board'".
|
|
||||||
///
|
///
|
||||||
/// So the trail an interim build writes is a trail the composer card only ever makes *more*
|
/// A one-line conformance over `CommitMessageEngine`, deliberately: the vocabulary is worth a file of
|
||||||
/// specific — no message written today becomes wrong tomorrow, and the root commit's subject is
|
/// its own and nothing about it should have to know that a protocol exists. The type stays because
|
||||||
/// already the settled one.
|
/// the seam takes an existential, and because a *named* default is what makes "the engine's composer
|
||||||
public struct InterimCommitMessage: CommitMessageComposing {
|
/// is the semantic one" assertable.
|
||||||
|
public struct SemanticCommitMessage: CommitMessageComposing {
|
||||||
/// 06's own mixed-window fallback.
|
|
||||||
public static let fallbackSubject = "Update board"
|
|
||||||
|
|
||||||
public init() {}
|
public init() {}
|
||||||
|
|
||||||
public func message(for request: CommitMessageRequest) -> String {
|
public func message(for request: CommitMessageRequest) -> String {
|
||||||
request.isRootCommit ? GitRepository.initialCommitSubject : Self.fallbackSubject
|
CommitMessageEngine.message(for: request)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -85,9 +85,11 @@ public final class GitAutoCommitter {
|
|||||||
@ObservationIgnored
|
@ObservationIgnored
|
||||||
public var holdRecheckInterval: Duration = .seconds(15)
|
public var holdRecheckInterval: Duration = .seconds(15)
|
||||||
|
|
||||||
/// **What a commit says** — the seam the semantic composer plugs into (next card).
|
/// **What a commit says** — the seam, holding the semantic composer by default
|
||||||
|
/// (06 ▸ Commit messages). Settable so a test can inject a fake and assert *that* a message was
|
||||||
|
/// asked for without asserting what it said.
|
||||||
@ObservationIgnored
|
@ObservationIgnored
|
||||||
public var composer: any CommitMessageComposing = InterimCommitMessage()
|
public var composer: any CommitMessageComposing = SemanticCommitMessage()
|
||||||
|
|
||||||
/// The board as the app last read it, for the composer's "current" half. `nil` where no store is
|
/// The board as the app last read it, for the composer's "current" half. `nil` where no store is
|
||||||
/// attached, which is every storeless test.
|
/// attached, which is every storeless test.
|
||||||
@@ -225,6 +227,12 @@ public final class GitAutoCommitter {
|
|||||||
/// stage-and-commit over a board-sized tree), and load-bearing (the alternative is losing a
|
/// stage-and-commit over a board-sized tree), and load-bearing (the alternative is losing a
|
||||||
/// version of somebody's file with no commit to recover it from).
|
/// version of somebody's file with no commit to recover it from).
|
||||||
///
|
///
|
||||||
|
/// **The semantic composer widened that bound**, and it is recorded rather than discovered: this
|
||||||
|
/// flush now also materializes HEAD's tree and reads it back through `BoardLoader`
|
||||||
|
/// (`composition(for:input:)`), so the synchronous cost is a few board-sized walks rather than
|
||||||
|
/// one. Still bounded and still rare — and the alternative, a placeholder message on exactly the
|
||||||
|
/// commit that preserves somebody else's version, would be the worst message in the trail.
|
||||||
|
///
|
||||||
/// **A foreign write the watcher has not delivered yet is invisible to it.** The gate learns
|
/// **A foreign write the watcher has not delivered yet is invisible to it.** The gate learns
|
||||||
/// about foreign changes from landed reloads, so a write that lands inside the watcher's own
|
/// about foreign changes from landed reloads, so a write that lands inside the watcher's own
|
||||||
/// debounce is not yet known to be pending. Bounded by that debounce, and the same window
|
/// debounce is not yet known to be pending. Bounded by that debounce, and the same window
|
||||||
@@ -370,18 +378,91 @@ public final class GitAutoCommitter {
|
|||||||
|
|
||||||
return GitCommitOperation.perform(
|
return GitCommitOperation.perform(
|
||||||
at: input.boardRoot,
|
at: input.boardRoot,
|
||||||
commits: plan(changed, reading: reading, input: input)
|
commits: plan(
|
||||||
|
changed,
|
||||||
|
reading: reading,
|
||||||
|
input: input,
|
||||||
|
composition: composition(for: changed, input: input)
|
||||||
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - What the composer is handed
|
||||||
|
|
||||||
|
/// **The composer's environment, resolved once per flush** (06 ▸ Commit messages: "a structural
|
||||||
|
/// diff of two board snapshots — last-committed vs. current").
|
||||||
|
///
|
||||||
|
/// Once per *flush*, not once per planned commit: a window that splits three ways
|
||||||
|
/// (foreign → heal → user) composes all three messages against the same HEAD, so materializing
|
||||||
|
/// HEAD's tree three times would be three answers to one question. Each message is then narrowed
|
||||||
|
/// to its own commit by `CommitMessageRequest.changedPaths`, which the split already narrows.
|
||||||
|
private struct Composition: Sendable {
|
||||||
|
var previous: BoardModel?
|
||||||
|
var current: BoardModel?
|
||||||
|
var agentGuideText: String?
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reads the two snapshots and the guide's bytes — the only impure step in the message path, kept
|
||||||
|
/// here so `CommitMessageEngine` can be a pure function of values.
|
||||||
|
///
|
||||||
|
/// **Skipped entirely when nothing in the window could touch the model**, which is the ordinary
|
||||||
|
/// stray-only and guide-only window: those compose path-shaped events, and materializing a board
|
||||||
|
/// twice to describe a changed `.gitignore` would be work with no reader.
|
||||||
|
private nonisolated static func composition(
|
||||||
|
for changed: [GitChangedPath],
|
||||||
|
input: FlushInput
|
||||||
|
) -> Composition {
|
||||||
|
var composition = Composition()
|
||||||
|
if changed.contains(where: { $0.path == AgentGuide.filename }) {
|
||||||
|
composition.agentGuideText = try? String(
|
||||||
|
contentsOf: input.boardRoot.appendingPathComponent(AgentGuide.filename),
|
||||||
|
encoding: .utf8
|
||||||
|
)
|
||||||
|
}
|
||||||
|
// **The comment family needs a board but not a diff.** Comments are outside the snapshot
|
||||||
|
// entirely (01-storage-format.md § Enhanced schema), so HEAD's tree has nothing to say about
|
||||||
|
// them — but the verbs name the *card* ("Comment on 'Fix login'"), and only a board knows a
|
||||||
|
// card's title. So a comment-only window loads the current board and skips the materialization.
|
||||||
|
let touchesModel = changed.contains { CommitMessageEngine.Paths.mightAffectSnapshot($0.path) }
|
||||||
|
let namesACard = changed.contains { CommentPath.classify($0.path) != nil }
|
||||||
|
guard touchesModel || namesACard else { return composition }
|
||||||
|
|
||||||
|
// **The store's snapshot when there is one, disk when there is not.** A storeless committer is
|
||||||
|
// a real configuration (`HistoryStore.compose` without a session, every engine-level test), and
|
||||||
|
// a composer handed no current board could only ever shrug. Loading here rather than in
|
||||||
|
// `makeInput` keeps the read off the main actor, where every other read in this flush already
|
||||||
|
// is.
|
||||||
|
composition.current = input.snapshot ?? (try? BoardLoader.load(boardRoot: input.boardRoot).model)
|
||||||
|
guard touchesModel else { return composition }
|
||||||
|
composition.previous = GitHeadSnapshot.load(at: input.boardRoot)
|
||||||
|
return composition
|
||||||
|
}
|
||||||
|
|
||||||
/// The three-way split turned into commits — or, on an unborn HEAD, the one commit 06 fixes.
|
/// The three-way split turned into commits — or, on an unborn HEAD, the one commit 06 fixes.
|
||||||
private nonisolated static func plan(
|
private nonisolated static func plan(
|
||||||
_ changed: [GitChangedPath],
|
_ changed: [GitChangedPath],
|
||||||
reading: GitRepositoryReading,
|
reading: GitRepositoryReading,
|
||||||
input: FlushInput
|
input: FlushInput,
|
||||||
|
composition: Composition
|
||||||
) -> [PlannedCommit] {
|
) -> [PlannedCommit] {
|
||||||
let user = GitCommitOperation.userIdentity(at: input.boardRoot)
|
let user = GitCommitOperation.userIdentity(at: input.boardRoot)
|
||||||
|
|
||||||
|
func request(
|
||||||
|
_ paths: [GitChangedPath],
|
||||||
|
_ authorship: CommitAuthorship,
|
||||||
|
isRootCommit: Bool = false
|
||||||
|
) -> CommitMessageRequest {
|
||||||
|
CommitMessageRequest(
|
||||||
|
boardRoot: input.boardRoot,
|
||||||
|
changedPaths: paths,
|
||||||
|
authorship: authorship,
|
||||||
|
isRootCommit: isRootCommit,
|
||||||
|
snapshot: composition.current,
|
||||||
|
previousSnapshot: composition.previous,
|
||||||
|
agentGuideText: composition.agentGuideText
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// **The root commit is not split** (06 ▸ Rules ▸ Abnormal repo states): "it commits the whole
|
// **The root commit is not split** (06 ▸ Rules ▸ Abnormal repo states): "it commits the whole
|
||||||
// tree as *Initial board state*, never a folded diff-from-empty: there is no last-committed
|
// tree as *Initial board state*, never a folded diff-from-empty: there is no last-committed
|
||||||
// snapshot to diff against". Splitting a repository's first commit three ways by the
|
// snapshot to diff against". Splitting a repository's first commit three ways by the
|
||||||
@@ -392,13 +473,7 @@ public final class GitAutoCommitter {
|
|||||||
guard !reading.isUnborn else {
|
guard !reading.isUnborn else {
|
||||||
return [PlannedCommit(
|
return [PlannedCommit(
|
||||||
paths: changed.map(\.path),
|
paths: changed.map(\.path),
|
||||||
message: input.composer.message(for: CommitMessageRequest(
|
message: input.composer.message(for: request(changed, .user, isRootCommit: true)),
|
||||||
boardRoot: input.boardRoot,
|
|
||||||
changedPaths: changed,
|
|
||||||
authorship: .user,
|
|
||||||
isRootCommit: true,
|
|
||||||
snapshot: input.snapshot
|
|
||||||
)),
|
|
||||||
author: user,
|
author: user,
|
||||||
committer: user
|
committer: user
|
||||||
)]
|
)]
|
||||||
@@ -424,13 +499,7 @@ public final class GitAutoCommitter {
|
|||||||
if case let .foreign(identity) = authorship { author = identity } else { author = user }
|
if case let .foreign(identity) = authorship { author = identity } else { author = user }
|
||||||
return PlannedCommit(
|
return PlannedCommit(
|
||||||
paths: group.paths.map(\.path),
|
paths: group.paths.map(\.path),
|
||||||
message: input.composer.message(for: CommitMessageRequest(
|
message: input.composer.message(for: request(group.paths, authorship)),
|
||||||
boardRoot: input.boardRoot,
|
|
||||||
changedPaths: group.paths,
|
|
||||||
authorship: authorship,
|
|
||||||
isRootCommit: false,
|
|
||||||
snapshot: input.snapshot
|
|
||||||
)),
|
|
||||||
author: author,
|
author: author,
|
||||||
committer: user
|
committer: user
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -90,10 +90,21 @@ public struct GitChangedPath: Sendable, Equatable, Hashable {
|
|||||||
/// deletion on disk that the window must not be demoted by.
|
/// deletion on disk that the window must not be demoted by.
|
||||||
public let isRename: Bool
|
public let isRename: Bool
|
||||||
|
|
||||||
public init(path: String, isDeletion: Bool, 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.path = path
|
||||||
self.isDeletion = isDeletion
|
self.isDeletion = isDeletion
|
||||||
self.isRename = isRename
|
self.isRename = isRename
|
||||||
|
self.isArrival = isArrival
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -331,7 +342,7 @@ enum GitCommitOperation {
|
|||||||
|
|
||||||
var found: [String: GitChangedPath] = [:]
|
var found: [String: GitChangedPath] = [:]
|
||||||
|
|
||||||
func record(_ path: String?, isDeletion: Bool, isRename: Bool) {
|
func record(_ path: String?, isDeletion: Bool, isRename: Bool, isArrival: Bool = false) {
|
||||||
guard let path, !path.isEmpty else { return }
|
guard let path, !path.isEmpty else { return }
|
||||||
let existing = found[path]
|
let existing = found[path]
|
||||||
found[path] = GitChangedPath(
|
found[path] = GitChangedPath(
|
||||||
@@ -339,7 +350,10 @@ enum GitCommitOperation {
|
|||||||
// Present wins where two deltas disagree: staging asks "is it there now", and the
|
// 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.
|
// `modified-by` demotion must not fire for a file the window ends with.
|
||||||
isDeletion: (existing?.isDeletion ?? true) && isDeletion,
|
isDeletion: (existing?.isDeletion ?? true) && isDeletion,
|
||||||
isRename: (existing?.isRename ?? false) || isRename
|
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
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -350,9 +364,12 @@ enum GitCommitOperation {
|
|||||||
record(string(delta.old_file.path), isDeletion: true, isRename: false)
|
record(string(delta.old_file.path), isDeletion: true, isRename: false)
|
||||||
case GIT_DELTA_RENAMED:
|
case GIT_DELTA_RENAMED:
|
||||||
// Both ends, and neither is a deletion the window may be demoted by: the departure
|
// 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.
|
// 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.old_file.path), isDeletion: true, isRename: true)
|
||||||
record(string(delta.new_file.path), isDeletion: false, 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:
|
default:
|
||||||
record(string(delta.new_file.path), isDeletion: false, isRename: false)
|
record(string(delta.new_file.path), isDeletion: false, isRename: false)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,158 @@
|
|||||||
|
import Foundation
|
||||||
|
import libgit2
|
||||||
|
import os
|
||||||
|
|
||||||
|
// MARK: - GitHeadSnapshot
|
||||||
|
|
||||||
|
/// **The last-committed half of the composer's diff** (06-history-undo.md ▸ Commit messages: "a
|
||||||
|
/// structural diff of two board snapshots — last-committed vs. current").
|
||||||
|
///
|
||||||
|
/// ### Why HEAD's tree, and not a snapshot carried forward
|
||||||
|
///
|
||||||
|
/// The engine could remember the board it committed last time and hand that back as "previous". It
|
||||||
|
/// deliberately does not, for four reasons, each of which is a case the carried value would get
|
||||||
|
/// wrong:
|
||||||
|
///
|
||||||
|
/// - **Launch catch-up has no previous to carry.** "Changes found pending at board open diff HEAD's
|
||||||
|
/// tree against the working tree through the same composer" (06) — at open the app's only snapshot
|
||||||
|
/// is the one it just loaded, which already *contains* the pending changes. The previous state
|
||||||
|
/// exists nowhere but in the repository.
|
||||||
|
/// - **The app is not the only writer.** An agent that commits its own work moves HEAD without the
|
||||||
|
/// app writing anything; a carried snapshot would diff against a state that is already history.
|
||||||
|
/// - **A failed or skipped commit does not advance history.** A carried value would advance anyway
|
||||||
|
/// and silently under-describe the next window.
|
||||||
|
/// - **It is checkable.** "Last committed" is a fact the repository answers; a carried value is a
|
||||||
|
/// claim the engine makes about itself, and nothing would ever catch it drifting.
|
||||||
|
///
|
||||||
|
/// The cost is this file: HEAD's tree is materialized into a temporary directory and read back
|
||||||
|
/// through the one `BoardLoader`, so the previous snapshot is produced by exactly the machinery that
|
||||||
|
/// produced the current one. Re-parsing rather than re-deriving is the point — two loaders would be
|
||||||
|
/// two definitions of what a board is.
|
||||||
|
///
|
||||||
|
/// ### What it writes
|
||||||
|
///
|
||||||
|
/// **`index.md` blobs in full; every other blob as a zero-byte placeholder.** The snapshot models
|
||||||
|
/// frontmatter, bodies and *attachment names* — never attachment bytes — so materializing a board's
|
||||||
|
/// images would copy megabytes per commit to answer a question about file names. Directories are
|
||||||
|
/// created so the shape the loader walks is the shape HEAD has.
|
||||||
|
///
|
||||||
|
/// ### Isolation
|
||||||
|
///
|
||||||
|
/// `GitCommitOperation`'s rule restated: `nonisolated`, opens its own `git_repository`, frees it in
|
||||||
|
/// the same synchronous scope, and no handle crosses an `await`. Called from inside the flush's
|
||||||
|
/// detached task, never from the main actor.
|
||||||
|
enum GitHeadSnapshot {
|
||||||
|
|
||||||
|
private static let logger = Logger(subsystem: "dev.rzen.indie.Kanban", category: "git")
|
||||||
|
|
||||||
|
/// libgit2's global state — `GitCommitOperation.startUp`'s twin and for its reason (a flush can
|
||||||
|
/// run when no `Repository` is alive, so this file cannot ride on SwiftGitX's refcount).
|
||||||
|
private static let startUp: Bool = {
|
||||||
|
git_libgit2_init() >= 0
|
||||||
|
}()
|
||||||
|
|
||||||
|
/// **The board as HEAD has it**, or `nil` when there is nothing to read one from: an unborn HEAD,
|
||||||
|
/// an unopenable repository, a tree with no board `index.md` in it.
|
||||||
|
///
|
||||||
|
/// `nil` is a *shrug*, not an error — the composer that receives it simply has no previous half
|
||||||
|
/// and falls back to describing the commit by its paths. Nothing here can fail a commit.
|
||||||
|
nonisolated static func load(at boardRoot: URL) -> BoardModel? {
|
||||||
|
_ = startUp
|
||||||
|
guard BoardGitMode.hasGitEntry(at: boardRoot) else { return nil }
|
||||||
|
var repository: OpaquePointer?
|
||||||
|
guard git_repository_open(&repository, boardRoot.path) == 0, let repository else { return nil }
|
||||||
|
defer { git_repository_free(repository) }
|
||||||
|
guard git_repository_head_unborn(repository) != 1 else { return nil }
|
||||||
|
|
||||||
|
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_TREE) == 0, let tree = object else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
defer { git_tree_free(tree) }
|
||||||
|
|
||||||
|
let scratch = FileManager.default.temporaryDirectory
|
||||||
|
.appendingPathComponent("LaneworkHeadSnapshot-\(UUID().uuidString)", isDirectory: true)
|
||||||
|
defer { try? FileManager.default.removeItem(at: scratch) }
|
||||||
|
guard (try? FileManager.default.createDirectory(at: scratch, withIntermediateDirectories: true)) != nil
|
||||||
|
else { return nil }
|
||||||
|
|
||||||
|
materialize(tree: tree, in: repository, into: scratch, depth: 0)
|
||||||
|
|
||||||
|
do {
|
||||||
|
return try BoardLoader.load(boardRoot: scratch).model
|
||||||
|
} catch {
|
||||||
|
// A HEAD whose tree the loader refuses — a board committed before `index.md` existed, a
|
||||||
|
// schema from the future — is simply not a previous snapshot. The window still commits;
|
||||||
|
// its message is composed from the paths alone.
|
||||||
|
logger.debug("HEAD's tree did not load as a board: \(error.description, privacy: .public)")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One tree level, recursively. Total and silent: a blob that cannot be read is skipped, because
|
||||||
|
/// a partial previous snapshot degrades one event's wording while a thrown error would cost the
|
||||||
|
/// commit its message entirely.
|
||||||
|
///
|
||||||
|
/// The depth cap is a guard against a pathological repository, not a statement about boards — a
|
||||||
|
/// board is three levels deep, four counting `attachments/`.
|
||||||
|
private static func materialize(
|
||||||
|
tree: OpaquePointer,
|
||||||
|
in repository: OpaquePointer,
|
||||||
|
into directory: URL,
|
||||||
|
depth: Int
|
||||||
|
) {
|
||||||
|
guard depth < 8 else { return }
|
||||||
|
let manager = FileManager.default
|
||||||
|
|
||||||
|
for position in 0..<git_tree_entrycount(tree) {
|
||||||
|
guard let entry = git_tree_entry_byindex(tree, position),
|
||||||
|
let rawName = git_tree_entry_name(entry) else { continue }
|
||||||
|
let name = String(cString: rawName)
|
||||||
|
guard !name.isEmpty, name != ".", name != ".." else { continue }
|
||||||
|
// A `/` in a tree entry name is impossible in a well-formed tree and would be a path
|
||||||
|
// escape if it were not: refuse rather than interpret.
|
||||||
|
guard !name.contains("/") else { continue }
|
||||||
|
|
||||||
|
switch git_tree_entry_type(entry) {
|
||||||
|
case GIT_OBJECT_TREE:
|
||||||
|
var child: OpaquePointer?
|
||||||
|
guard let id = git_tree_entry_id(entry),
|
||||||
|
git_tree_lookup(&child, repository, id) == 0,
|
||||||
|
let child else { continue }
|
||||||
|
defer { git_tree_free(child) }
|
||||||
|
let folder = directory.appendingPathComponent(name, isDirectory: true)
|
||||||
|
guard (try? manager.createDirectory(at: folder, withIntermediateDirectories: true)) != nil
|
||||||
|
else { continue }
|
||||||
|
materialize(tree: child, in: repository, into: folder, depth: depth + 1)
|
||||||
|
|
||||||
|
case GIT_OBJECT_BLOB:
|
||||||
|
let file = directory.appendingPathComponent(name)
|
||||||
|
// **Content for `index.md`, a placeholder for everything else.** The loader reads
|
||||||
|
// frontmatter and bodies out of the first and only the *names* of the rest.
|
||||||
|
guard name == IntegrityRules.indexFileName else {
|
||||||
|
try? Data().write(to: file)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
var blob: OpaquePointer?
|
||||||
|
guard let id = git_tree_entry_id(entry),
|
||||||
|
git_blob_lookup(&blob, repository, id) == 0,
|
||||||
|
let blob else { continue }
|
||||||
|
defer { git_blob_free(blob) }
|
||||||
|
let size = Int(git_blob_rawsize(blob))
|
||||||
|
let bytes = git_blob_rawcontent(blob)
|
||||||
|
let data = (bytes != nil && size > 0)
|
||||||
|
? Data(bytes: bytes!, count: size)
|
||||||
|
: Data()
|
||||||
|
try? data.write(to: file)
|
||||||
|
|
||||||
|
default:
|
||||||
|
// Submodules and symlinks: neither is a board, and neither is followed anywhere else
|
||||||
|
// in this app either (`BoardLoader.directoryCandidates` excludes links).
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -103,7 +103,8 @@ struct AutoCommitDebounceTests {
|
|||||||
|
|
||||||
#expect(committer.commitCount == 1)
|
#expect(committer.commitCount == 1)
|
||||||
#expect(isClean(at: fixture.root), "a flush leaves nothing dirty — branch switch depends on it")
|
#expect(isClean(at: fixture.root), "a flush leaves nothing dirty — branch switch depends on it")
|
||||||
#expect(try headSubject(at: fixture.root) == InterimCommitMessage.fallbackSubject)
|
// The message is the semantic composer's, end to end — no placeholder anywhere in the path.
|
||||||
|
#expect(try headSubject(at: fixture.root) == "Add card 'Second'")
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("A burst of changes inside one window is one commit, not one per change")
|
@Test("A burst of changes inside one window is one commit, not one per change")
|
||||||
@@ -893,6 +894,151 @@ struct EditSessionBoundaryTests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Semantic messages, through the whole engine
|
||||||
|
|
||||||
|
/// The composer's own vocabulary is proved without a repository in `CommitMessageTests`. What is
|
||||||
|
/// proved here is the wiring: that a **real commit**, made by the real engine over real libgit2,
|
||||||
|
/// carries the composed message — HEAD's tree read for the last-committed half, the working tree for
|
||||||
|
/// the current one, the split's own paths narrowing each message to its own commit.
|
||||||
|
@MainActor
|
||||||
|
@Suite("Auto-commit ▸ semantic messages")
|
||||||
|
struct AutoCommitMessageTests {
|
||||||
|
|
||||||
|
@Test("A foreign change composes identically to an app-mediated one — only the author differs")
|
||||||
|
func originIsNotInTheProse() async throws {
|
||||||
|
// 06 ▸ The external gap, closed: "Origin lives in the author field (structural attribution),
|
||||||
|
// not in message prose." Two boards, the same rename, one vouched for and one not.
|
||||||
|
func rename(vouchedFor: Bool) async throws -> CommitRecord {
|
||||||
|
let (fixture, git, ledger) = try await makeGitBoard()
|
||||||
|
defer { fixture.tearDown() }
|
||||||
|
let committer = try quickCommitter(git)
|
||||||
|
|
||||||
|
let text = plain(order: "1024", title: "Renamed")
|
||||||
|
try fixture.item("\(Ident.lane1)/\(Ident.card1)", text)
|
||||||
|
if vouchedFor {
|
||||||
|
ledger.recordWrite(
|
||||||
|
at: fixture.url("\(Ident.lane1)/\(Ident.card1)")
|
||||||
|
.appendingPathComponent(BoardLoader.indexFileName),
|
||||||
|
text: text
|
||||||
|
)
|
||||||
|
committer.noteWriteBracketClosed()
|
||||||
|
} else {
|
||||||
|
committer.noteReloadLanded(sawForeignChange: true)
|
||||||
|
}
|
||||||
|
await committer.flushNow()
|
||||||
|
return try #require(try history(at: fixture.root).first)
|
||||||
|
}
|
||||||
|
|
||||||
|
let app = try await rename(vouchedFor: true)
|
||||||
|
let foreign = try await rename(vouchedFor: false)
|
||||||
|
#expect(app.subject == "Rename card 'First' → 'Renamed'")
|
||||||
|
#expect(foreign.subject == app.subject, "the message engine is origin-agnostic by design")
|
||||||
|
// …and the author is the only thing that differs.
|
||||||
|
#expect(app.authorEmail != CommitAttribution.externalAuthorEmail)
|
||||||
|
#expect(foreign.authorEmail == CommitAttribution.externalAuthorEmail)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Board-open catch-up carries a real composed message, not a placeholder")
|
||||||
|
func launchCatchUpComposes() async throws {
|
||||||
|
// "Changes found pending at board open diff HEAD's tree against the working tree through the
|
||||||
|
// same composer, instead of committing blind" (06). Nothing signals this window: no reload
|
||||||
|
// landed, no bracket closed, and no snapshot was ever handed to the committer — the previous
|
||||||
|
// board can only have come from HEAD.
|
||||||
|
let (fixture, git, _) = try await makeGitBoard()
|
||||||
|
defer { fixture.tearDown() }
|
||||||
|
let committer = try quickCommitter(git)
|
||||||
|
|
||||||
|
try fixture.item("\(Ident.lane1)/\(Ident.card2)", plain(order: "2048", title: "Written while closed"))
|
||||||
|
committer.start()
|
||||||
|
|
||||||
|
try await waitUntil { committer.commitCount == 1 }
|
||||||
|
#expect(try headSubject(at: fixture.root) == "Add card 'Written while closed'")
|
||||||
|
#expect(isClean(at: fixture.root))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("The guide write auto-commits as 'Update agent guide (vN)'")
|
||||||
|
func theGuideComposesItsVersion() async throws {
|
||||||
|
// The m10 agent-guide card's deferred git bullet, landing here: N is read from the marker
|
||||||
|
// line of the bytes on disk (`AgentGuide.installedVersion`), never tagged at the write site.
|
||||||
|
let (fixture, git, _) = try await makeGitBoard()
|
||||||
|
defer { fixture.tearDown() }
|
||||||
|
let committer = try quickCommitter(git)
|
||||||
|
|
||||||
|
// An older guide, committed — so the window is a genuine guide *upgrade*: HEAD's bytes carry
|
||||||
|
// v1 and the working tree's carry the version this build ships.
|
||||||
|
try fixture.file(AgentGuide.filename, Data("<!-- lanework-agent-guide v1 -->\nOld.\n".utf8))
|
||||||
|
committer.noteReloadLanded(sawForeignChange: true)
|
||||||
|
await committer.flushNow()
|
||||||
|
#expect(try headSubject(at: fixture.root) == "Update agent guide (v1)")
|
||||||
|
|
||||||
|
_ = try AgentGuide.install(atBoardRoot: fixture.root)
|
||||||
|
committer.noteWriteBracketClosed()
|
||||||
|
await committer.flushNow()
|
||||||
|
|
||||||
|
#expect(try headSubject(at: fixture.root) == "Update agent guide (v\(AgentGuide.version))")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("A split window's two commits each describe only their own paths")
|
||||||
|
func eachCommitDescribesItsOwnPaths() async throws {
|
||||||
|
// Both messages compose against the same HEAD, so the only thing that can keep them apart is
|
||||||
|
// the changed-path list each commit stages — the filter, proved end to end.
|
||||||
|
let (fixture, git, ledger) = try await makeGitBoard()
|
||||||
|
defer { fixture.tearDown() }
|
||||||
|
let committer = try quickCommitter(git)
|
||||||
|
|
||||||
|
try fixture.item("\(Ident.lane1)/\(Ident.card1)", plain(order: "1024", title: "Touched by an agent"))
|
||||||
|
|
||||||
|
let text = plain(order: "2048", title: "Added by the user")
|
||||||
|
let card = try fixture.item("\(Ident.lane1)/\(Ident.card2)", text)
|
||||||
|
ledger.recordWrite(at: card.appendingPathComponent(BoardLoader.indexFileName), text: text)
|
||||||
|
|
||||||
|
committer.noteWriteBracketClosed()
|
||||||
|
committer.noteReloadLanded(sawForeignChange: true)
|
||||||
|
await committer.flushNow()
|
||||||
|
|
||||||
|
let log = try history(at: fixture.root)
|
||||||
|
#expect(committer.commitCount == 2)
|
||||||
|
// Newest first: the user's overwrite lands after the foreign version it might have buried.
|
||||||
|
#expect(log.first?.subject == "Add card 'Added by the user'")
|
||||||
|
#expect(log.dropFirst().first?.subject == "Rename card 'First' → 'Touched by an agent'")
|
||||||
|
#expect(log.dropFirst().first?.authorEmail == CommitAttribution.externalAuthorEmail)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("A comment lands in the trail by its own verb, through real libgit2")
|
||||||
|
func commentsComposeTheirFamily() async throws {
|
||||||
|
// The one part of the comment family that cannot be proved without a repository: "is this
|
||||||
|
// path new" is `GIT_DELTA_ADDED`, read off the real diff — the fact that tells a post from an
|
||||||
|
// edit where the snapshot has nothing to say (01-storage-format.md § Enhanced schema).
|
||||||
|
let (fixture, git, _) = try await makeGitBoard()
|
||||||
|
defer { fixture.tearDown() }
|
||||||
|
let committer = try quickCommitter(git)
|
||||||
|
let comment = "\(Ident.lane1)/\(Ident.card1)/comments/cccccccc-0000-4000-8000-000000000001"
|
||||||
|
|
||||||
|
try fixture.file("\(comment)/index.md", Data("---\nschema: 1\nkind: comment\n---\nLooks good.\n".utf8))
|
||||||
|
committer.noteReloadLanded(sawForeignChange: true)
|
||||||
|
await committer.flushNow()
|
||||||
|
#expect(try headSubject(at: fixture.root) == "Comment on 'First'")
|
||||||
|
|
||||||
|
try fixture.file("\(comment)/index.md", Data("---\nschema: 1\nkind: comment\n---\nOn reflection.\n".utf8))
|
||||||
|
committer.noteReloadLanded(sawForeignChange: true)
|
||||||
|
await committer.flushNow()
|
||||||
|
#expect(try headSubject(at: fixture.root) == "Edit comment on 'First'")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("A stray-only window names the stray rather than shrugging")
|
||||||
|
func straysAreNamed() async throws {
|
||||||
|
let (fixture, git, _) = try await makeGitBoard()
|
||||||
|
defer { fixture.tearDown() }
|
||||||
|
let committer = try quickCommitter(git)
|
||||||
|
|
||||||
|
try fixture.file("notes.txt", Data("scratch\n".utf8))
|
||||||
|
committer.noteReloadLanded(sawForeignChange: true)
|
||||||
|
await committer.flushNow()
|
||||||
|
|
||||||
|
#expect(try headSubject(at: fixture.root) == "Update 'notes.txt'")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Attribution, as a pure function
|
// MARK: - Attribution, as a pure function
|
||||||
|
|
||||||
@Suite("Auto-commit ▸ attribution rules")
|
@Suite("Auto-commit ▸ attribution rules")
|
||||||
|
|||||||
@@ -0,0 +1,824 @@
|
|||||||
|
import Foundation
|
||||||
|
import Testing
|
||||||
|
@testable import Kanban
|
||||||
|
|
||||||
|
/// **The semantic commit-message engine** (06-history-undo.md ▸ Commit messages) — the vocabulary,
|
||||||
|
/// the folding, the trash pair, the bookkeeping silence, the path-shaped events, and the external
|
||||||
|
/// gap the section exists to close.
|
||||||
|
///
|
||||||
|
/// **No repository anywhere in this file.** The composer is "a pure, testable function": two board
|
||||||
|
/// snapshots and a changed-path list in, one message out. Both snapshots are written as bytes and
|
||||||
|
/// read back through the real `BoardLoader` (`WriterFixture.snapshot()`'s reason, restated: a
|
||||||
|
/// hand-assembled `BoardModel` would be a value the loader can never produce), and the changed-path
|
||||||
|
/// list is derived by comparing the two trees — which is what git would have reported, arrived at
|
||||||
|
/// without git. The engine-level half — that a real commit carries this message, by the real author —
|
||||||
|
/// lives in `AutoCommitTests`.
|
||||||
|
|
||||||
|
// MARK: - Fixtures
|
||||||
|
|
||||||
|
/// The board both snapshots start as: three lanes, two cards in the first.
|
||||||
|
private func baseBoard(_ fixture: WriterFixture) throws {
|
||||||
|
try fixture.board(title: "Board")
|
||||||
|
try fixture.lane(Ident.lane1, order: "1024", title: "Todo")
|
||||||
|
try fixture.lane(Ident.lane2, order: "2048", title: "Doing")
|
||||||
|
try fixture.lane(Ident.lane3, order: "3072", title: "Done")
|
||||||
|
try fixture.card(Ident.card1, in: Ident.lane1, order: "1024", title: "Fix login")
|
||||||
|
try fixture.card(Ident.card2, in: Ident.lane1, order: "2048", title: "Ship it")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A UUID-shaped name `Ident` does not already spend on the base board — the trash suites need one
|
||||||
|
/// more identity than the fixture offers, and reusing a live card's would be a duplicate the loader
|
||||||
|
/// would rightly withhold.
|
||||||
|
private let spareIdentity = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"
|
||||||
|
|
||||||
|
/// Every file under a root, keyed by its board-root-relative path in git's spelling.
|
||||||
|
private func files(under root: URL) -> [String: Data] {
|
||||||
|
var found: [String: Data] = [:]
|
||||||
|
let manager = FileManager.default
|
||||||
|
guard let walker = manager.enumerator(atPath: root.path) else { return found }
|
||||||
|
for case let relative as String in walker {
|
||||||
|
let url = root.appendingPathComponent(relative)
|
||||||
|
var isDirectory: ObjCBool = false
|
||||||
|
guard manager.fileExists(atPath: url.path, isDirectory: &isDirectory), !isDirectory.boolValue
|
||||||
|
else { continue }
|
||||||
|
found[relative] = (try? Data(contentsOf: url)) ?? Data()
|
||||||
|
}
|
||||||
|
return found
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What `GitCommitOperation.surveyChangedPaths` would have reported for these two trees — a plain
|
||||||
|
/// content comparison, since nothing here has a repository to ask.
|
||||||
|
private func changedPaths(from before: URL, to after: URL) -> [GitChangedPath] {
|
||||||
|
let old = files(under: before)
|
||||||
|
let new = files(under: after)
|
||||||
|
var paths: [GitChangedPath] = []
|
||||||
|
for (path, data) in new where old[path] != data {
|
||||||
|
// `isArrival` is git's own `GIT_DELTA_ADDED`, which here is simply "HEAD did not have it".
|
||||||
|
paths.append(GitChangedPath(
|
||||||
|
path: path,
|
||||||
|
isDeletion: false,
|
||||||
|
isRename: false,
|
||||||
|
isArrival: old[path] == nil
|
||||||
|
))
|
||||||
|
}
|
||||||
|
for path in old.keys where new[path] == nil {
|
||||||
|
paths.append(GitChangedPath(path: path, isDeletion: true, isRename: false))
|
||||||
|
}
|
||||||
|
return paths.sorted { $0.path < $1.path }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Builds the same board twice, changes one copy, and composes the message for the difference.
|
||||||
|
///
|
||||||
|
/// - Parameters:
|
||||||
|
/// - board: the state both snapshots start in.
|
||||||
|
/// - change: what happened — applied to the "after" copy only.
|
||||||
|
/// - authorship: the commit's class. **Defaults to `.user`, and almost every test leaves it there**
|
||||||
|
/// because 06 rules that it must not matter: "a foreign move reads 'Move card …' exactly like an
|
||||||
|
/// app-mediated one". The one suite that varies it proves exactly that.
|
||||||
|
/// - renames: paths the survey would have paired as renames, for the shapes that turn on it.
|
||||||
|
private func compose(
|
||||||
|
board: (WriterFixture) throws -> Void = baseBoard,
|
||||||
|
change: (WriterFixture) throws -> Void,
|
||||||
|
authorship: CommitAuthorship = .user,
|
||||||
|
renames: Set<String> = [],
|
||||||
|
guideText: String? = nil
|
||||||
|
) throws -> String {
|
||||||
|
let before = try WriterFixture()
|
||||||
|
defer { before.tearDown() }
|
||||||
|
let after = try WriterFixture()
|
||||||
|
defer { after.tearDown() }
|
||||||
|
|
||||||
|
try board(before)
|
||||||
|
try board(after)
|
||||||
|
try change(after)
|
||||||
|
|
||||||
|
let paths = changedPaths(from: before.root, to: after.root).map { path in
|
||||||
|
renames.contains(path.path)
|
||||||
|
? GitChangedPath(
|
||||||
|
path: path.path,
|
||||||
|
isDeletion: path.isDeletion,
|
||||||
|
isRename: true,
|
||||||
|
isArrival: path.isArrival
|
||||||
|
)
|
||||||
|
: path
|
||||||
|
}
|
||||||
|
return CommitMessageEngine.message(for: CommitMessageRequest(
|
||||||
|
boardRoot: after.root,
|
||||||
|
changedPaths: paths,
|
||||||
|
authorship: authorship,
|
||||||
|
isRootCommit: false,
|
||||||
|
snapshot: try after.snapshot(),
|
||||||
|
previousSnapshot: try before.snapshot(),
|
||||||
|
agentGuideText: guideText
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
private func subject(of message: String) -> String {
|
||||||
|
String(message.split(separator: "\n", omittingEmptySubsequences: false).first ?? "")
|
||||||
|
}
|
||||||
|
|
||||||
|
private func body(of message: String) -> [String] {
|
||||||
|
let lines = message.split(separator: "\n", omittingEmptySubsequences: false).map(String.init)
|
||||||
|
guard lines.count > 2 else { return [] }
|
||||||
|
return Array(lines.dropFirst(2))
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - One event, one subject
|
||||||
|
|
||||||
|
@Suite("Commit messages ▸ a single event is the subject")
|
||||||
|
struct CommitMessageSingleEventTests {
|
||||||
|
|
||||||
|
@Test("Adding a card names the card, with its destination as the detail")
|
||||||
|
func addingACard() throws {
|
||||||
|
let message = try compose { fixture in
|
||||||
|
try fixture.card(Ident.card3, in: Ident.lane2, order: "1024", title: "Write tests")
|
||||||
|
}
|
||||||
|
#expect(subject(of: message) == "Add card 'Write tests'")
|
||||||
|
#expect(body(of: message) == ["to Doing"])
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("A cross-lane move reads as a move — never delete plus add")
|
||||||
|
func movingACard() throws {
|
||||||
|
// The whole-board id match: the folder left one lane and arrived in another, and the
|
||||||
|
// composer is asked to tell that from a deletion beside an arrival.
|
||||||
|
let message = try compose { fixture in
|
||||||
|
try fixture.moveFolder("\(Ident.lane1)/\(Ident.card1)", to: "\(Ident.lane3)/\(Ident.card1)")
|
||||||
|
}
|
||||||
|
#expect(subject(of: message) == "Move card 'Fix login' to Done")
|
||||||
|
#expect(body(of: message) == ["Todo → Done"])
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("A rename carries both names in the subject — 06's own form")
|
||||||
|
func renamingALane() throws {
|
||||||
|
let message = try compose { fixture in
|
||||||
|
try fixture.lane(Ident.lane1, order: "1024", title: "Backlog")
|
||||||
|
}
|
||||||
|
#expect(message == "Rename lane 'Todo' → 'Backlog'")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("An edited body is an Edit, and a restyle is a Restyle")
|
||||||
|
func editingAndRestyling() throws {
|
||||||
|
let edited = try compose { fixture in
|
||||||
|
try fixture.card(Ident.card1, in: Ident.lane1, order: "1024", title: "Fix login", body: "Now with detail.")
|
||||||
|
}
|
||||||
|
#expect(edited == "Edit card 'Fix login'")
|
||||||
|
|
||||||
|
let restyled = try compose { fixture in
|
||||||
|
try fixture.item(
|
||||||
|
"\(Ident.lane1)/\(Ident.card1)",
|
||||||
|
"---\nschema: 1\ntitle: Fix login\norder: 1024\nbackground: blue\n---\n\n"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
#expect(restyled == "Restyle card 'Fix login'")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("A widened lane is a Resize")
|
||||||
|
func resizingALane() throws {
|
||||||
|
let message = try compose { fixture in
|
||||||
|
try fixture.lane(Ident.lane1, order: "1024", title: "Todo", width: 2)
|
||||||
|
}
|
||||||
|
#expect(message == "Resize lane 'Todo' to 2×")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("An attachment composes Attach, named by the card it landed on")
|
||||||
|
func attachingAFile() throws {
|
||||||
|
let message = try compose { fixture in
|
||||||
|
try fixture.file("\(Ident.lane1)/\(Ident.card1)/attachments/spec.pdf", Data("pdf".utf8))
|
||||||
|
}
|
||||||
|
#expect(message == "Attach 'spec.pdf' to card 'Fix login'")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("A removed attachment composes Remove")
|
||||||
|
func removingAFile() throws {
|
||||||
|
let message = try compose { fixture in
|
||||||
|
try baseBoard(fixture)
|
||||||
|
try fixture.file("\(Ident.lane1)/\(Ident.card1)/attachments/spec.pdf", Data("pdf".utf8))
|
||||||
|
} change: { fixture in
|
||||||
|
try FileManager.default.removeItem(
|
||||||
|
at: fixture.url("\(Ident.lane1)/\(Ident.card1)/attachments").appendingPathComponent("spec.pdf")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
#expect(message == "Remove 'spec.pdf' from card 'Fix login'")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("A repositioned card composes Reorder, naming its lane")
|
||||||
|
func reorderingCards() throws {
|
||||||
|
// A *foreign* single-file reorder: one card's rank crosses its sibling's, nothing else
|
||||||
|
// changes. "An order change that repositions an item among its siblings composes Reorder."
|
||||||
|
let message = try compose { fixture in
|
||||||
|
try fixture.card(Ident.card1, in: Ident.lane1, order: "4096", title: "Fix login")
|
||||||
|
}
|
||||||
|
#expect(message == "Reorder cards in Todo")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("The board's own fields compose board events")
|
||||||
|
func boardLevelEvents() throws {
|
||||||
|
let renamed = try compose { fixture in
|
||||||
|
try fixture.board(title: "Q3 Plan")
|
||||||
|
}
|
||||||
|
#expect(renamed == "Rename board 'Board' → 'Q3 Plan'")
|
||||||
|
|
||||||
|
let described = try compose { fixture in
|
||||||
|
try fixture.board(title: "Board", body: "What this board is for.")
|
||||||
|
}
|
||||||
|
#expect(described == "Edit board description")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - The trash pair
|
||||||
|
|
||||||
|
@Suite("Commit messages ▸ the trash pair, by diff shape alone")
|
||||||
|
struct CommitMessageTrashTests {
|
||||||
|
|
||||||
|
@Test("A move into .trash/ is Delete")
|
||||||
|
func intoTheTrashIsDelete() throws {
|
||||||
|
let message = try compose { fixture in
|
||||||
|
try fixture.move("\(Ident.lane1)/\(Ident.card1)", toTrash: Ident.card1)
|
||||||
|
}
|
||||||
|
#expect(message == "Delete card 'Fix login'")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("A move out of .trash/ is Restore")
|
||||||
|
func outOfTheTrashIsRestore() throws {
|
||||||
|
let message = try compose { fixture in
|
||||||
|
try baseBoard(fixture)
|
||||||
|
try fixture.trashCard(Ident.card3, order: "1024", title: "Old idea")
|
||||||
|
} change: { fixture in
|
||||||
|
try fixture.move(".trash/\(Ident.card3)", toLane: Ident.lane2, card: Ident.card3)
|
||||||
|
}
|
||||||
|
#expect(message == "Restore card 'Old idea'")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Leaving the tree entirely is Permanently delete")
|
||||||
|
func leavingTheTreeIsPermanent() throws {
|
||||||
|
let message = try compose { fixture in
|
||||||
|
try baseBoard(fixture)
|
||||||
|
try fixture.trashCard(Ident.card3, order: "1024", title: "Old idea")
|
||||||
|
} change: { fixture in
|
||||||
|
try FileManager.default.removeItem(at: fixture.url(".trash/\(Ident.card3)"))
|
||||||
|
}
|
||||||
|
#expect(message == "Permanently delete card 'Old idea'")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Empty Trash folds — 'Permanently delete 3 cards', distinct from a multi-select delete")
|
||||||
|
func emptyTrashFolds() throws {
|
||||||
|
let purge = try compose { fixture in
|
||||||
|
try baseBoard(fixture)
|
||||||
|
try fixture.trashCard(Ident.card3, order: "1024", title: "One")
|
||||||
|
try fixture.trashCard(Ident.card4, order: "2048", title: "Two")
|
||||||
|
try fixture.trashCard(spareIdentity, order: "3072", title: "Three")
|
||||||
|
} change: { fixture in
|
||||||
|
try FileManager.default.removeItem(at: fixture.url(".trash"))
|
||||||
|
}
|
||||||
|
#expect(subject(of: purge) == "Permanently delete 3 cards")
|
||||||
|
|
||||||
|
let delete = try compose { fixture in
|
||||||
|
try baseBoard(fixture)
|
||||||
|
try fixture.card(Ident.card3, in: Ident.lane1, order: "3072", title: "Third")
|
||||||
|
} change: { fixture in
|
||||||
|
for card in [Ident.card1, Ident.card2, Ident.card3] {
|
||||||
|
try fixture.move("\(Ident.lane1)/\(card)", toTrash: card)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#expect(subject(of: delete) == "Delete 3 cards")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("A trashed lane takes its cards with it — 'Delete lane', cards as bullets")
|
||||||
|
func aTrashedLaneKeepsTheSubject() throws {
|
||||||
|
// "Implied events don't steal the subject: deleting a lane with five cards reads 'Delete lane
|
||||||
|
// 'X'' with the card deletions as body bullets — not 'Update board'." And the cards are
|
||||||
|
// *deleted*, not permanently deleted: they went into the trash inside their lane's folder.
|
||||||
|
let message = try compose { fixture in
|
||||||
|
try fixture.moveFolder(Ident.lane1, to: ".trash/\(Ident.lane1)")
|
||||||
|
}
|
||||||
|
#expect(subject(of: message) == "Delete lane 'Todo'")
|
||||||
|
#expect(body(of: message) == [
|
||||||
|
"- Delete lane 'Todo'",
|
||||||
|
"- Delete card 'Fix login'",
|
||||||
|
"- Delete card 'Ship it'",
|
||||||
|
])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Folding
|
||||||
|
|
||||||
|
@Suite("Commit messages ▸ folding")
|
||||||
|
struct CommitMessageFoldingTests {
|
||||||
|
|
||||||
|
@Test("Several of one kind fold, and a shared destination survives into the subject")
|
||||||
|
func sharedDestinationsFold() throws {
|
||||||
|
let message = try compose { fixture in
|
||||||
|
try baseBoard(fixture)
|
||||||
|
try fixture.card(Ident.card3, in: Ident.lane1, order: "3072", title: "Third")
|
||||||
|
} change: { fixture in
|
||||||
|
for card in [Ident.card1, Ident.card2, Ident.card3] {
|
||||||
|
try fixture.moveFolder("\(Ident.lane1)/\(card)", to: "\(Ident.lane3)/\(card)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#expect(subject(of: message) == "Move 3 cards to Done")
|
||||||
|
#expect(body(of: message).count == 3)
|
||||||
|
#expect(body(of: message).first == "- Move card 'Fix login' from Todo to Done")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Destinations that disagree drop out of the subject rather than lying")
|
||||||
|
func disagreeingDestinationsDropOut() throws {
|
||||||
|
let message = try compose(change: { fixture in
|
||||||
|
try fixture.moveFolder("\(Ident.lane1)/\(Ident.card1)", to: "\(Ident.lane2)/\(Ident.card1)")
|
||||||
|
try fixture.moveFolder("\(Ident.lane1)/\(Ident.card2)", to: "\(Ident.lane3)/\(Ident.card2)")
|
||||||
|
})
|
||||||
|
#expect(subject(of: message) == "Move 2 cards")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("A genuinely mixed window is 'Update board' — with every event in the body")
|
||||||
|
func mixedWindowsFallBack() throws {
|
||||||
|
let message = try compose { fixture in
|
||||||
|
try fixture.card(Ident.card1, in: Ident.lane1, order: "1024", title: "Fix logout")
|
||||||
|
try fixture.card(Ident.card3, in: Ident.lane2, order: "1024", title: "Write tests")
|
||||||
|
}
|
||||||
|
#expect(subject(of: message) == CommitMessageEngine.mixedSubject)
|
||||||
|
#expect(body(of: message) == [
|
||||||
|
"- Add card 'Write tests' to Doing",
|
||||||
|
"- Rename card 'Fix login' → 'Fix logout'",
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("A deleted lane's cards are body bullets, never the subject")
|
||||||
|
func impliedEventsDoNotStealTheSubject() throws {
|
||||||
|
let message = try compose { fixture in
|
||||||
|
try FileManager.default.removeItem(at: fixture.url(Ident.lane1))
|
||||||
|
}
|
||||||
|
#expect(subject(of: message) == "Permanently delete lane 'Todo'")
|
||||||
|
#expect(body(of: message) == [
|
||||||
|
"- Permanently delete lane 'Todo'",
|
||||||
|
"- Permanently delete card 'Fix login'",
|
||||||
|
"- Permanently delete card 'Ship it'",
|
||||||
|
])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - The external gap
|
||||||
|
|
||||||
|
@Suite("Commit messages ▸ the external gap, closed")
|
||||||
|
struct CommitMessageExternalSurfaceTests {
|
||||||
|
|
||||||
|
@Test("Labels, assignees and due dates each compose a named subject")
|
||||||
|
func theReservedTrioComposes() throws {
|
||||||
|
func withKey(_ line: String) throws -> String {
|
||||||
|
try compose { fixture in
|
||||||
|
try fixture.item(
|
||||||
|
"\(Ident.lane1)/\(Ident.card1)",
|
||||||
|
"---\nschema: 1\ntitle: Fix login\norder: 1024\n\(line)\n---\n\n"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#expect(try withKey("labels: [bug, urgent]") == "Relabel card 'Fix login'")
|
||||||
|
#expect(try withKey("assignees: [ada]") == "Assign card 'Fix login'")
|
||||||
|
#expect(try withKey("due: 2026-08-31") == "Set due date on card 'Fix login'")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("An unmodeled custom key composes a named generic — never a board-level shrug")
|
||||||
|
func customKeysComposeANamedGeneric() throws {
|
||||||
|
let message = try compose { fixture in
|
||||||
|
try fixture.item(
|
||||||
|
"\(Ident.lane1)/\(Ident.card1)",
|
||||||
|
"---\nschema: 1\ntitle: Fix login\norder: 1024\nsprint: 42\nestimate: 3\n---\n\n"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
// Two custom keys, one event: the item is what is named, not the keys.
|
||||||
|
#expect(message == "Update card 'Fix login'")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("A foreign change composes identically to an app-mediated one")
|
||||||
|
func originIsNotInTheProse() throws {
|
||||||
|
// "Origin lives in the author field (structural attribution), not in message prose — a foreign
|
||||||
|
// move reads 'Move card …' exactly like an app-mediated one." Same change, all three classes.
|
||||||
|
func move(_ authorship: CommitAuthorship) throws -> String {
|
||||||
|
try compose(change: { fixture in
|
||||||
|
try fixture.moveFolder("\(Ident.lane1)/\(Ident.card1)", to: "\(Ident.lane2)/\(Ident.card1)")
|
||||||
|
}, authorship: authorship)
|
||||||
|
}
|
||||||
|
let app = try move(.user)
|
||||||
|
let foreign = try move(.foreign(GitIdentity(name: "Lanework External", email: "[email protected]")))
|
||||||
|
let heal = try move(.heal)
|
||||||
|
#expect(app == "Move card 'Fix login' to Doing\n\nTodo → Doing")
|
||||||
|
#expect(foreign == app)
|
||||||
|
#expect(heal == app)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Bookkeeping
|
||||||
|
|
||||||
|
@Suite("Commit messages ▸ bookkeeping composes nothing")
|
||||||
|
struct CommitMessageBookkeepingTests {
|
||||||
|
|
||||||
|
@Test("A bumped modified stamp is not an event")
|
||||||
|
func stampsAreSilent() throws {
|
||||||
|
let message = try compose { fixture in
|
||||||
|
try fixture.card(
|
||||||
|
Ident.card1, in: Ident.lane1, order: "1024", title: "Fix login",
|
||||||
|
modified: "2026-07-31T12:00:00Z"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
#expect(message == CommitMessageEngine.mixedSubject)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("A renumber's rescale preserves sequence and composes nothing")
|
||||||
|
func rescalesAreSilent() throws {
|
||||||
|
// Every rank multiplied, nobody repositioned — 01-storage-format.md's renumber. "Sequence is
|
||||||
|
// what the diff compares, not raw `order` values."
|
||||||
|
let message = try compose { fixture in
|
||||||
|
try fixture.card(Ident.card1, in: Ident.lane1, order: "16384", title: "Fix login")
|
||||||
|
try fixture.card(Ident.card2, in: Ident.lane1, order: "32768", title: "Ship it")
|
||||||
|
}
|
||||||
|
#expect(message == CommitMessageEngine.mixedSubject)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("An on-touch heal's backfilled kind is not an event")
|
||||||
|
func theBackfilledKindIsSilent() throws {
|
||||||
|
let message = try compose { fixture in
|
||||||
|
try fixture.item(
|
||||||
|
"\(Ident.lane1)/\(Ident.card1)",
|
||||||
|
"---\nschema: 1\ntitle: Fix login\norder: 1024\nkind: card\n---\n\n"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
#expect(message == CommitMessageEngine.mixedSubject)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("A renumber batches with the insert that triggered it")
|
||||||
|
func aRenumberRidesWithItsInsert() throws {
|
||||||
|
// Midpoint exhaustion: the insert forces every sibling's rank to be rewritten. The commit
|
||||||
|
// reads as the insert, because the *sequence* of the cards that were already there is
|
||||||
|
// unchanged.
|
||||||
|
let message = try compose { fixture in
|
||||||
|
try fixture.card(Ident.card1, in: Ident.lane1, order: "1024", title: "Fix login")
|
||||||
|
try fixture.card(Ident.card3, in: Ident.lane1, order: "2048", title: "Wedged in")
|
||||||
|
try fixture.card(Ident.card2, in: Ident.lane1, order: "3072", title: "Ship it")
|
||||||
|
}
|
||||||
|
#expect(subject(of: message) == "Add card 'Wedged in'")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Titles
|
||||||
|
|
||||||
|
@Suite("Commit messages ▸ titles")
|
||||||
|
struct CommitMessageTitleTests {
|
||||||
|
|
||||||
|
@Test("Titles truncate in subjects only; bodies carry them whole")
|
||||||
|
func truncationIsSubjectOnly() throws {
|
||||||
|
let long = String(repeating: "A", count: 60)
|
||||||
|
let message = try compose { fixture in
|
||||||
|
try fixture.card(Ident.card3, in: Ident.lane2, order: "1024", title: long)
|
||||||
|
try fixture.card(Ident.card4, in: Ident.lane2, order: "2048", title: long)
|
||||||
|
}
|
||||||
|
#expect(subject(of: message) == "Add 2 cards to Doing")
|
||||||
|
#expect(body(of: message).allSatisfy { $0.contains(long) })
|
||||||
|
|
||||||
|
let sole = try compose { fixture in
|
||||||
|
try fixture.card(Ident.card3, in: Ident.lane2, order: "1024", title: long)
|
||||||
|
}
|
||||||
|
#expect(subject(of: sole) == "Add card '\(String(repeating: "A", count: 40))…'")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("An untitled item reads (untitled), never a bare pair of quotes")
|
||||||
|
func untitledIsRendered() throws {
|
||||||
|
let message = try compose { fixture in
|
||||||
|
try fixture.item("\(Ident.lane2)/\(Ident.card3)", "---\nschema: 1\norder: 1024\n---\n\n")
|
||||||
|
}
|
||||||
|
#expect(message == "Add card '(untitled)'\n\nto Doing")
|
||||||
|
#expect(!message.contains("''"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Non-snapshot paths
|
||||||
|
|
||||||
|
@Suite("Commit messages ▸ non-snapshot files")
|
||||||
|
struct CommitMessagePathEventTests {
|
||||||
|
|
||||||
|
@Test("A stray composes its own path-shaped event, and several fold")
|
||||||
|
func straysComposePathEvents() throws {
|
||||||
|
let one = try compose { fixture in
|
||||||
|
try fixture.file("notes.txt", Data("hello".utf8))
|
||||||
|
}
|
||||||
|
#expect(one == "Update 'notes.txt'")
|
||||||
|
|
||||||
|
let several = try compose { fixture in
|
||||||
|
try fixture.file("notes.txt", Data("hello".utf8))
|
||||||
|
try fixture.file(".gitignore", Data("*.tmp\n".utf8))
|
||||||
|
try fixture.file("\(Ident.lane1)/\(Ident.card1)/scratch.md", Data("scratch".utf8))
|
||||||
|
}
|
||||||
|
#expect(subject(of: several) == "Update 3 files")
|
||||||
|
#expect(body(of: several) == [
|
||||||
|
"- Update '.gitignore'",
|
||||||
|
"- Update '\(Ident.lane1)/\(Ident.card1)/scratch.md'",
|
||||||
|
"- Update 'notes.txt'",
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("The agent guide composes its version, read from the marker line")
|
||||||
|
func theAgentGuideComposesItsVersion() throws {
|
||||||
|
let guide = "<!-- lanework-agent-guide v7 -->\nHow to work in this board.\n"
|
||||||
|
let message = try compose(change: { fixture in
|
||||||
|
try fixture.file(AgentGuide.filename, Data(guide.utf8))
|
||||||
|
}, guideText: guide)
|
||||||
|
#expect(message == "Update agent guide (v7)")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("A markerless CLAUDE.md is somebody else's file, and composes as the path it is")
|
||||||
|
func amarkerlessGuideIsJustAFile() throws {
|
||||||
|
let text = "My own notes for agents.\n"
|
||||||
|
let message = try compose(change: { fixture in
|
||||||
|
try fixture.file(AgentGuide.filename, Data(text.utf8))
|
||||||
|
}, guideText: text)
|
||||||
|
#expect(message == "Update 'CLAUDE.md'")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Model events keep the subject; a stray rides along as a bullet")
|
||||||
|
func modelEventsKeepTheSubject() throws {
|
||||||
|
let message = try compose { fixture in
|
||||||
|
try fixture.card(Ident.card3, in: Ident.lane2, order: "1024", title: "Write tests")
|
||||||
|
try fixture.file("notes.txt", Data("hello".utf8))
|
||||||
|
}
|
||||||
|
#expect(subject(of: message) == "Add card 'Write tests'")
|
||||||
|
#expect(body(of: message) == [
|
||||||
|
"- Add card 'Write tests' to Doing",
|
||||||
|
"- Update 'notes.txt'",
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("A path this commit does not stage composes nothing — the stage-around, in the message")
|
||||||
|
func pathsOutsideTheCommitAreNotDescribed() throws {
|
||||||
|
// The split window's rule, and the open-Edit-session exclusion's: the composer describes the
|
||||||
|
// commit it is composing for, not everything that differs from HEAD.
|
||||||
|
let before = try WriterFixture()
|
||||||
|
defer { before.tearDown() }
|
||||||
|
let after = try WriterFixture()
|
||||||
|
defer { after.tearDown() }
|
||||||
|
try baseBoard(before)
|
||||||
|
try baseBoard(after)
|
||||||
|
try after.card(Ident.card1, in: Ident.lane1, order: "1024", title: "Fix login", body: "Half-typed.")
|
||||||
|
try after.card(Ident.card3, in: Ident.lane2, order: "1024", title: "Write tests")
|
||||||
|
|
||||||
|
let staged = changedPaths(from: before.root, to: after.root)
|
||||||
|
.filter { !$0.path.hasPrefix("\(Ident.lane1)/\(Ident.card1)/") }
|
||||||
|
let message = CommitMessageEngine.message(for: CommitMessageRequest(
|
||||||
|
boardRoot: after.root,
|
||||||
|
changedPaths: staged,
|
||||||
|
authorship: .user,
|
||||||
|
isRootCommit: false,
|
||||||
|
snapshot: try after.snapshot(),
|
||||||
|
previousSnapshot: try before.snapshot()
|
||||||
|
))
|
||||||
|
#expect(message == "Add card 'Write tests'\n\nto Doing")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - The comment verb family
|
||||||
|
|
||||||
|
@Suite("Commit messages ▸ the comment verb family")
|
||||||
|
struct CommitMessageCommentTests {
|
||||||
|
|
||||||
|
private static let commentA = "cccccccc-0000-4000-8000-000000000001"
|
||||||
|
private static let commentB = "cccccccc-0000-4000-8000-000000000002"
|
||||||
|
private static let commentC = "cccccccc-0000-4000-8000-000000000003"
|
||||||
|
|
||||||
|
/// A comment's `index.md` — never parsed by the composer, which reads the family off path shape
|
||||||
|
/// alone ("comments are window-scoped, outside the board snapshot" — 01-storage-format.md).
|
||||||
|
private static func commentText(_ body: String) -> Data {
|
||||||
|
Data("---\nschema: 1\nkind: comment\nauthor: Ada\n---\n\(body)\n".utf8)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func thread(_ card: String, _ entry: String) -> String {
|
||||||
|
"\(Ident.lane1)/\(card)/comments/\(entry)/index.md"
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Posting — the .draft rename — composes 'Comment on ⟨card⟩'")
|
||||||
|
func postingComposesComment() throws {
|
||||||
|
// The app's post is one rename: `comments/.draft/` → `comments/<uuid>/`, restamped in the
|
||||||
|
// same bracket. The departing `.draft` end is silent under the rename rule; the arrival is
|
||||||
|
// the event.
|
||||||
|
let draft = Self.thread(Ident.card1, ".draft")
|
||||||
|
let posted = Self.thread(Ident.card1, Self.commentA)
|
||||||
|
let message = try compose(board: { fixture in
|
||||||
|
try baseBoard(fixture)
|
||||||
|
try fixture.file(draft, Self.commentText("Half a thought."))
|
||||||
|
}, change: { fixture in
|
||||||
|
try fixture.file(posted, Self.commentText("Half a thought."))
|
||||||
|
try FileManager.default.removeItem(
|
||||||
|
at: fixture.root.appendingPathComponent("\(Ident.lane1)/\(Ident.card1)/comments/.draft")
|
||||||
|
)
|
||||||
|
}, renames: [draft, posted])
|
||||||
|
#expect(message == "Comment on 'Fix login'")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("A foreign writer's new comment folder composes the same words")
|
||||||
|
func foreignArrivalsComposeTheSame() throws {
|
||||||
|
// No rename to read: an agent simply wrote a folder. Origin-agnostic one level down.
|
||||||
|
let message = try compose { fixture in
|
||||||
|
try fixture.file(Self.thread(Ident.card1, Self.commentA), Self.commentText("From an agent."))
|
||||||
|
}
|
||||||
|
#expect(message == "Comment on 'Fix login'")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Rewriting an existing comment composes 'Edit comment on ⟨card⟩'")
|
||||||
|
func editsComposeEditComment() throws {
|
||||||
|
let message = try compose { fixture in
|
||||||
|
try baseBoard(fixture)
|
||||||
|
try fixture.file(Self.thread(Ident.card1, Self.commentA), Self.commentText("First draft."))
|
||||||
|
} change: { fixture in
|
||||||
|
try fixture.file(Self.thread(Ident.card1, Self.commentA), Self.commentText("Second thoughts."))
|
||||||
|
}
|
||||||
|
#expect(message == "Edit comment on 'Fix login'")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("A move into comments/.trash/ composes one Delete, not a departure plus an arrival")
|
||||||
|
func deletionComposesDeleteComment() throws {
|
||||||
|
let message = try compose { fixture in
|
||||||
|
try baseBoard(fixture)
|
||||||
|
try fixture.file(Self.thread(Ident.card1, Self.commentA), Self.commentText("Regretted."))
|
||||||
|
} change: { fixture in
|
||||||
|
try fixture.moveFolder(
|
||||||
|
"\(Ident.lane1)/\(Ident.card1)/comments/\(Self.commentA)",
|
||||||
|
to: "\(Ident.lane1)/\(Ident.card1)/comments/.trash/\(Self.commentA)"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
// Both halves are in the window and git paired neither of them; the arriving end speaks.
|
||||||
|
#expect(message == "Delete comment on 'Fix login'")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("The close purge composes 'Permanently delete comment on ⟨card⟩'")
|
||||||
|
func purgeComposesPermanentDelete() throws {
|
||||||
|
// `comments/.trash/` is undo's backing store, purged when the card window closes. Unruled in
|
||||||
|
// DESIGN; composed by the trash pair's own symmetry rather than left as a silent shrug.
|
||||||
|
let message = try compose { fixture in
|
||||||
|
try baseBoard(fixture)
|
||||||
|
try fixture.file(
|
||||||
|
"\(Ident.lane1)/\(Ident.card1)/comments/.trash/\(Self.commentA)/index.md",
|
||||||
|
Self.commentText("Deleted a while ago.")
|
||||||
|
)
|
||||||
|
} change: { fixture in
|
||||||
|
try FileManager.default.removeItem(
|
||||||
|
at: fixture.root.appendingPathComponent("\(Ident.lane1)/\(Ident.card1)/comments/.trash")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
#expect(message == "Permanently delete comment on 'Fix login'")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("A draft save composes the quiet 'Draft comment on ⟨card⟩'")
|
||||||
|
func draftSavesComposeDraftComment() throws {
|
||||||
|
let message = try compose { fixture in
|
||||||
|
try baseBoard(fixture)
|
||||||
|
try fixture.file(Self.thread(Ident.card1, ".draft"), Self.commentText("Still typing"))
|
||||||
|
} change: { fixture in
|
||||||
|
try fixture.file(Self.thread(Ident.card1, ".draft"), Self.commentText("Still typing, more"))
|
||||||
|
}
|
||||||
|
#expect(message == "Draft comment on 'Fix login'")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Every file in one comment folder is one event")
|
||||||
|
func oneCommentIsOneEvent() throws {
|
||||||
|
let message = try compose { fixture in
|
||||||
|
try fixture.file(Self.thread(Ident.card1, Self.commentA), Self.commentText("With a file."))
|
||||||
|
try fixture.file(
|
||||||
|
"\(Ident.lane1)/\(Ident.card1)/comments/\(Self.commentA)/attachments/shot.png",
|
||||||
|
Data("png".utf8)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
#expect(message == "Comment on 'Fix login'")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Several comments fold on the card they landed on")
|
||||||
|
func commentsFoldOnTheirCard() throws {
|
||||||
|
let sameCard = try compose { fixture in
|
||||||
|
for comment in [Self.commentA, Self.commentB, Self.commentC] {
|
||||||
|
try fixture.file(Self.thread(Ident.card1, comment), Self.commentText("Note."))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#expect(subject(of: sameCard) == "3 comments on 'Fix login'")
|
||||||
|
|
||||||
|
let twoCards = try compose { fixture in
|
||||||
|
try fixture.file(Self.thread(Ident.card1, Self.commentA), Self.commentText("Note."))
|
||||||
|
try fixture.file(Self.thread(Ident.card2, Self.commentB), Self.commentText("Note."))
|
||||||
|
}
|
||||||
|
#expect(subject(of: twoCards) == "Comment on 2 cards")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("The card's title is resolved from the snapshot, untitled included")
|
||||||
|
func titlesResolveFromTheSnapshot() throws {
|
||||||
|
let message = try compose { fixture in
|
||||||
|
try baseBoard(fixture)
|
||||||
|
try fixture.item("\(Ident.lane2)/\(Ident.card3)", "---\nschema: 1\norder: 1024\n---\n\n")
|
||||||
|
} change: { fixture in
|
||||||
|
try fixture.file(
|
||||||
|
"\(Ident.lane2)/\(Ident.card3)/comments/\(Self.commentA)/index.md",
|
||||||
|
Self.commentText("On a nameless card.")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
#expect(message == "Comment on '(untitled)'")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("A comment-only window never shrugs and never shows a raw path")
|
||||||
|
func aCommentOnlyWindowGetsItsOwnSubject() throws {
|
||||||
|
let message = try compose { fixture in
|
||||||
|
try fixture.file(Self.thread(Ident.card1, Self.commentA), Self.commentText("Note."))
|
||||||
|
}
|
||||||
|
#expect(message != CommitMessageEngine.mixedSubject)
|
||||||
|
#expect(!message.contains("comments/"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Model events keep the subject; a comment rides along as a bullet")
|
||||||
|
func modelEventsOutrankComments() throws {
|
||||||
|
let message = try compose { fixture in
|
||||||
|
try fixture.moveFolder("\(Ident.lane1)/\(Ident.card2)", to: "\(Ident.lane3)/\(Ident.card2)")
|
||||||
|
try fixture.file(Self.thread(Ident.card1, Self.commentA), Self.commentText("Note."))
|
||||||
|
}
|
||||||
|
#expect(subject(of: message) == "Move card 'Ship it' to Done")
|
||||||
|
#expect(body(of: message).contains("- Comment on 'Fix login'"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("A card carrying its thread into the trash is one event, not one per comment")
|
||||||
|
func aMovedCardSwallowsItsThread() throws {
|
||||||
|
// The implied-events rule, one level down: the comment files travelled because the card did.
|
||||||
|
let message = try compose { fixture in
|
||||||
|
try baseBoard(fixture)
|
||||||
|
for comment in [Self.commentA, Self.commentB] {
|
||||||
|
try fixture.file(Self.thread(Ident.card1, comment), Self.commentText("Note."))
|
||||||
|
}
|
||||||
|
} change: { fixture in
|
||||||
|
try fixture.move("\(Ident.lane1)/\(Ident.card1)", toTrash: Ident.card1)
|
||||||
|
}
|
||||||
|
#expect(message == "Delete card 'Fix login'")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Repair
|
||||||
|
|
||||||
|
@Suite("Commit messages ▸ Repair")
|
||||||
|
struct CommitMessageRepairTests {
|
||||||
|
|
||||||
|
/// The remint's exact shape, assembled rather than provoked.
|
||||||
|
///
|
||||||
|
/// What `BoardWriter.remintDuplicateIdentity` does on disk is rename the losing folder in place to
|
||||||
|
/// a fresh identity, so the survey reports a rename pair. What the *snapshots* show is only the
|
||||||
|
/// arrival — because the previous load withheld the duplicate (`BoardLoader.dedupeIdentities`),
|
||||||
|
/// its id was never in a board model at all. Provoking that by writing two folders with one id
|
||||||
|
/// would make the test depend on which twin the dedupe happened to keep; the shape the composer
|
||||||
|
/// is asked about is this, and it is stated directly.
|
||||||
|
private func remintMessage(authorship: CommitAuthorship) throws -> String {
|
||||||
|
let before = try WriterFixture()
|
||||||
|
defer { before.tearDown() }
|
||||||
|
let after = try WriterFixture()
|
||||||
|
defer { after.tearDown() }
|
||||||
|
try baseBoard(before)
|
||||||
|
try baseBoard(after)
|
||||||
|
try after.card(Ident.card4, in: Ident.lane1, order: "1024", title: "Fix login")
|
||||||
|
|
||||||
|
let departed = "\(Ident.lane1)/\(Ident.card3)/index.md"
|
||||||
|
let arrived = "\(Ident.lane1)/\(Ident.card4)/index.md"
|
||||||
|
let paths = changedPaths(from: before.root, to: after.root).map { path in
|
||||||
|
path.path == arrived
|
||||||
|
? GitChangedPath(path: arrived, isDeletion: false, isRename: true, isArrival: true)
|
||||||
|
: path
|
||||||
|
} + [GitChangedPath(path: departed, isDeletion: true, isRename: true)]
|
||||||
|
|
||||||
|
return CommitMessageEngine.message(for: CommitMessageRequest(
|
||||||
|
boardRoot: after.root,
|
||||||
|
changedPaths: paths,
|
||||||
|
authorship: authorship,
|
||||||
|
isRootCommit: false,
|
||||||
|
snapshot: try after.snapshot(),
|
||||||
|
previousSnapshot: try before.snapshot()
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("A heal window's folder remint reads as Repair, not as an arrival")
|
||||||
|
func theRemintComposesRepair() throws {
|
||||||
|
#expect(try remintMessage(authorship: .heal) == "Repair duplicate of 'Fix login'")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("The same shape outside a heal window is an ordinary arrival")
|
||||||
|
func repairIsHealOnly() throws {
|
||||||
|
#expect(subject(of: try remintMessage(authorship: .user)) == "Add card 'Fix login'")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - The root commit
|
||||||
|
|
||||||
|
@Suite("Commit messages ▸ the root commit")
|
||||||
|
struct CommitMessageRootCommitTests {
|
||||||
|
|
||||||
|
@Test("The repository's first commit has the one fixed subject")
|
||||||
|
func theRootCommitIsFixed() throws {
|
||||||
|
let fixture = try WriterFixture()
|
||||||
|
defer { fixture.tearDown() }
|
||||||
|
try baseBoard(fixture)
|
||||||
|
let message = CommitMessageEngine.message(for: CommitMessageRequest(
|
||||||
|
boardRoot: fixture.root,
|
||||||
|
changedPaths: changedPaths(from: fixture.root, to: fixture.root),
|
||||||
|
authorship: .user,
|
||||||
|
isRootCommit: true,
|
||||||
|
snapshot: try fixture.snapshot(),
|
||||||
|
previousSnapshot: nil
|
||||||
|
))
|
||||||
|
#expect(message == GitRepository.initialCommitSubject)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -63,7 +63,7 @@ Lanework is in early development. This list tracks what has actually shipped and
|
|||||||
|
|
||||||
- **Git integration (Lanework Pro)** — git is **opt-in per board and never silent**. Under a subscription, a board's mode is detected freshly at every open, nearest-`.git`-wins: a `.git` at the board root means git mode, a `.git` only further up means the board lives inside somebody else's repository, and neither means no git at all. Detection is an open-time fact by design — a `git init` run in a terminal under an open board takes effect the next time you open it, and nothing watches for a repository appearing. **Adoption is not initialization**: a board whose folder already holds a repository (you cloned it, or you ran `git init` yourself) simply opens in git mode, with no dialog and no adoption step — the repository's presence *is* the opt-in, which is how a second machine joins a shared board. **Add Git** in the board popover is the only thing in the app that ever creates one: it initializes a repository in the board's folder and immediately commits the whole tree as "Initial board state", so the board is protected from the moment git exists, and it flips the open board into git mode on the spot. A board inside an existing repository is left strictly alone — no nested repository, no commits into your project — and the popover says so in plain words instead of showing a disabled button. The git client is **bundled** (libgit2, in-process via SwiftGitX): nothing here shells out, and none of it needs git installed. Git history also settles duplicate-id collisions on git boards — of two folders claiming one identity, the path that entered history first wins.
|
- **Git integration (Lanework Pro)** — git is **opt-in per board and never silent**. Under a subscription, a board's mode is detected freshly at every open, nearest-`.git`-wins: a `.git` at the board root means git mode, a `.git` only further up means the board lives inside somebody else's repository, and neither means no git at all. Detection is an open-time fact by design — a `git init` run in a terminal under an open board takes effect the next time you open it, and nothing watches for a repository appearing. **Adoption is not initialization**: a board whose folder already holds a repository (you cloned it, or you ran `git init` yourself) simply opens in git mode, with no dialog and no adoption step — the repository's presence *is* the opt-in, which is how a second machine joins a shared board. **Add Git** in the board popover is the only thing in the app that ever creates one: it initializes a repository in the board's folder and immediately commits the whole tree as "Initial board state", so the board is protected from the moment git exists, and it flips the open board into git mode on the spot. A board inside an existing repository is left strictly alone — no nested repository, no commits into your project — and the popover says so in plain words instead of showing a disabled button. The git client is **bundled** (libgit2, in-process via SwiftGitX): nothing here shells out, and none of it needs git installed. Git history also settles duplicate-id collisions on git boards — of two folders claiming one identity, the path that entered history first wins.
|
||||||
|
|
||||||
- **Auto-commit (Lanework Pro)** — on a git board **every settled change becomes a commit**, debounced a couple of seconds past the churn of a drag or a burst of typing, so one gesture is one commit rather than forty. Agent and hand edits ride the same debounce, so work done in a terminal or by an agent is committed, attributed and undoable exactly like your own. What commits is the **tree**, not the app's model: strays, `CLAUDE.md` and anything else `git status` shows go in too (your `.gitignore` is respected), so a board is never left quietly dirty. **Commit authorship is structural**: the app knows, file by file, what it wrote and what it didn't, so your changes are authored as you while changes from outside are authored as `Lanework External <[email protected]>` — and when every file in an outside batch carries the same `modified-by:` stamp, that batch is authored as that agent instead. A window holding both kinds is split into two commits rather than mixed, outside changes first, and a scheduled repair's files commit separately again. Identity comes from the repository's own `.git/config` when it names one, and otherwise from your macOS account name and machine. **The card editor is the exception, deliberately**: its 700 ms saves keep the file crash-safe but stay uncommitted for the length of a session, and the body lands as exactly one commit when you leave Edit — so a lane move made while you're typing commits the move and steps around the card you're in. Closing a board window or quitting flushes everything pending before teardown, and a board opened with uncommitted changes commits them through the same engine. When another program holds the repository's index, the committer waits, retries briefly, and then simply tries again at the next quiet moment — never an error, and the lock is never removed, because it isn't the app's. A merge, rebase, cherry-pick or detached HEAD left by outside-the-app git **pauses** committing entirely rather than writing into a state the app didn't create; your edits keep landing on disk and commit as one batch when you've finished up in whichever tool started it. Messages read "Update board" for now — the semantic message engine ("Move card 'Fix login' to Doing") is the next card. Still ahead in pro-m1/m2: git-backed undo/redo, branch switching, `.gitignore` seeding, and remotes.
|
- **Auto-commit (Lanework Pro)** — on a git board **every settled change becomes a commit**, debounced a couple of seconds past the churn of a drag or a burst of typing, so one gesture is one commit rather than forty. Agent and hand edits ride the same debounce, so work done in a terminal or by an agent is committed, attributed and undoable exactly like your own. What commits is the **tree**, not the app's model: strays, `CLAUDE.md` and anything else `git status` shows go in too (your `.gitignore` is respected), so a board is never left quietly dirty. **Commit authorship is structural**: the app knows, file by file, what it wrote and what it didn't, so your changes are authored as you while changes from outside are authored as `Lanework External <[email protected]>` — and when every file in an outside batch carries the same `modified-by:` stamp, that batch is authored as that agent instead. A window holding both kinds is split into two commits rather than mixed, outside changes first, and a scheduled repair's files commit separately again. Identity comes from the repository's own `.git/config` when it names one, and otherwise from your macOS account name and machine. **The card editor is the exception, deliberately**: its 700 ms saves keep the file crash-safe but stay uncommitted for the length of a session, and the body lands as exactly one commit when you leave Edit — so a lane move made while you're typing commits the move and steps around the card you're in. Closing a board window or quitting flushes everything pending before teardown, and a board opened with uncommitted changes commits them through the same engine. When another program holds the repository's index, the committer waits, retries briefly, and then simply tries again at the next quiet moment — never an error, and the lock is never removed, because it isn't the app's. A merge, rebase, cherry-pick or detached HEAD left by outside-the-app git **pauses** committing entirely rather than writing into a state the app didn't create; your edits keep landing on disk and commit as one batch when you've finished up in whichever tool started it. **What each commit says is composed, not templated**: at commit time the app diffs the board as HEAD has it against the board as it is now — never by watching what you clicked — and writes what actually happened. "Move card 'Fix login' to Doing". "Rename lane 'Todo' → 'Doing'". "Relabel card 'Fix login'". Items are matched by identity across the whole board, so a card dragged between lanes reads as a move rather than a deletion beside an addition, and a folder moved by hand in the Finder reads exactly the same. Several changes of one kind fold into one line with the destination kept ("Move 3 cards to Done"); a genuinely mixed batch reads "Update board" with every event listed underneath, so `git log --oneline` stays scannable and the full message stays complete. Deleting a lane says "Delete lane 'Todo'" with its cards as detail rather than burying the event in a count. The trash is told apart by shape alone: into `.trash/` is "Delete card 'X'", back out is "Restore card 'X'", and gone for good is "Permanently delete card 'X'" — so the log distinguishes moved-to-trash from gone-forever without being told which gesture you used. Files the board model doesn't cover are described too rather than silently swept in: a changed agent guide reads "Update agent guide (v7)", a comment gets its own verbs off its path shape — "Comment on 'Fix login'", "Edit comment on…", "Delete comment on…", and the quiet "Draft comment on…" for the composer's slow saves — and any other stray reads "Update 'notes.txt'". Bookkeeping stays out of it — a bumped timestamp, a rank rescale, or a backfilled key composes nothing. And because the origin of a change lives in the author field, **an agent's commit reads in exactly the same words as your own**. Still ahead in pro-m1/m2: git-backed undo/redo, branch switching, `.gitignore` seeding, and remotes.
|
||||||
|
|
||||||
## Development
|
## Development
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user