Repository navigation
Expand file tree
/
Copy pathcommit_executor.go
More file actions
568 lines (529 loc) · 23.5 KB
/
Copy pathcommit_executor.go
File metadata and controls
568 lines (529 loc) · 23.5 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
// SPDX-License-Identifier: Apache-2.0
package git
import (
"context"
"errors"
"fmt"
"path/filepath"
"slices"
"time"
gogit "github.com/go-git/go-git/v6"
"github.com/go-git/go-git/v6/plumbing"
"sigs.k8s.io/controller-runtime/pkg/log"
"github.com/ConfigButler/gitops-reverser/internal/manifestanalyzer"
)
// executePendingWrites commits each write in order on top of the checkout, and stops at the first
// that fails, undoing the whole batch. It is the first commit of a decided write; a replay of
// writes committed before goes through replayPendingWrites instead.
func (w *BranchWorker) executePendingWrites(
ctx context.Context,
repo *gogit.Repository,
pendingWrites []PendingWrite,
) (int, error) {
commitsCreated, _, err := w.runPendingWrites(ctx, repo, pendingWrites, false)
return commitsCreated, err
}
// replayPendingWrites commits retained writes again onto a fresh root, and returns how many of them
// the checkout holds afterwards.
//
// A write is accepted against the tree it was first committed on, and the tree it is replayed onto
// can refuse it: somebody else changed its folder in the meantime. That refusal is a final answer
// about that one write, and the writes after it have no reason to wait on it, so the replay skips
// it and goes on. The skipped write is stamped with its refusal (replayRefusal), and the loop
// settles it the way it settles a refusal at first commit. Any other failure says nothing final
// about the write it hit, so it abandons the whole replay, as before, and nothing is stamped.
func (w *BranchWorker) replayPendingWrites(
ctx context.Context,
repo *gogit.Repository,
pendingWrites []PendingWrite,
) (int, error) {
// A stamp describes the checkout the last completed replay produced, so a replay that starts
// clears them all, and only one that completes leaves any behind.
for i := range pendingWrites {
pendingWrites[i].replayRefusal = nil
}
_, refusals, err := w.runPendingWrites(ctx, repo, pendingWrites, true)
if err != nil {
return 0, err
}
for i, refusal := range refusals {
pendingWrites[i].replayRefusal = refusal
}
return len(pendingWrites) - len(refusals), nil
}
// runPendingWrites is the loop both execute the writes with. With skipRefused, a write the
// acceptance gate refuses is undone on its own and recorded in the returned map by index, instead
// of failing the batch.
func (w *BranchWorker) runPendingWrites(
ctx context.Context,
repo *gogit.Repository,
pendingWrites []PendingWrite,
skipRefused bool,
) (int, map[int]error, error) {
worktree, err := repo.Worktree()
if err != nil {
return 0, nil, fmt.Errorf("failed to get worktree: %w", err)
}
// Where the batch starts, so a write that fails part-way can be undone locally: see
// restoreWorktree. An unborn branch has no commit to return to, and falls back to a reset.
start := headOr(repo, plumbing.ZeroHash)
commitsCreated := 0
refusals := map[int]error{}
// Index over the slice so the per-write commit hash is written back onto the
// caller's PendingWrite (§6.5): a CommitRequest riding a write resolves to this
// SHA on push, and a rebase-replay (which re-runs this loop on the retained
// writes) refreshes it to the post-rebase hash.
for i := range pendingWrites {
// Where this write starts: a refused write is undone back to here, keeping the writes
// before it.
before := headOr(repo, start)
created, hash, source, err := w.executePendingWrite(ctx, repo, worktree, &pendingWrites[i])
if err != nil && skipRefused && undoRefusedWrite(repo, before, err) {
refusals[i] = err
continue
}
if err != nil {
// A write can fail after it has staged part of its change, so the worktree may now
// hold content nobody asked for, and a cycle that planned on top of it would commit it
// under an unrelated author. Returning to where the batch started clears it without a
// round trip; only if that fails is the worktree left for a reset from the remote.
if restoreErr := restoreWorktree(repo, start); restoreErr != nil {
w.Log.V(1).Info("Could not undo a failed write locally", "error", restoreErr.Error())
w.markWorktreeDirty("execute pending write failed")
}
return commitsCreated, nil, err
}
pendingWrites[i].CommitSHA = hash
pendingWrites[i].emptyCommitted = pendingWrites[i].mayCommitEmpty() && isEmptyCommit(repo, hash)
// Stamped for the same reason the hash is: publishCommitsForPush runs after the push and
// would otherwise recompute the source from the write, which cannot know that a
// requestTemplate render failed back here at commit time.
pendingWrites[i].committedMessageSource = source
commitsCreated += created
}
return commitsCreated, refusals, nil
}
// headOr is the commit HEAD points at, or fallback when it cannot be read.
func headOr(repo *gogit.Repository, fallback plumbing.Hash) plumbing.Hash {
if head, err := repo.Head(); err == nil {
return head.Hash()
}
return fallback
}
// undoRefusedWrite reports whether err is the acceptance gate refusing a write, and that write was
// undone back to before, the commit it started on. A refusal that cannot be undone fails the batch
// like any other error.
func undoRefusedWrite(repo *gogit.Repository, before plumbing.Hash, err error) bool {
var refused *manifestanalyzer.AcceptanceRefusedError
return errors.As(err, &refused) && restoreWorktree(repo, before) == nil
}
// windowTarget is the GitTarget a window's write belongs to: the target its events were routed
// to, as the window recorded it when it opened.
func (p PendingWrite) windowTarget() (string, string) {
for i := range p.Events {
if p.Events[i].GitTargetName != "" {
return p.Events[i].GitTargetName, p.Events[i].GitTargetNamespace
}
}
return p.GitTargetName, p.GitTargetNamespace
}
// restoreWorktree undoes a batch that failed part-way: the branch goes back to the commit the batch
// started on, discarding any commits it made, and the worktree back to that commit's tree, leftovers
// included (discardWorktreeLeftovers). The checkout then holds exactly what it held before the
// batch, which is what checkoutApplied still says, so nothing has to be fetched.
func restoreWorktree(repo *gogit.Repository, start plumbing.Hash) error {
if start.IsZero() {
return errors.New("the branch had no commit to return to")
}
worktree, err := repo.Worktree()
if err != nil {
return fmt.Errorf("get worktree: %w", err)
}
if err := worktree.Reset(&gogit.ResetOptions{Commit: start, Mode: gogit.HardReset}); err != nil {
return fmt.Errorf("reset to %s: %w", start, err)
}
return discardWorktreeLeftovers(repo)
}
// retained reports whether the loop keeps this write once it is committed: a write that changes
// nothing in Git does not exist (docs/design/applier-echoes.md, F2), so a write whose plan left no
// action, a window or a resync alike, is neither retained nor pushed. A save is the exception: it
// keeps its objects, and the push decides its outcome (resolvePushedCommitRequests). The checkout
// counts the same writes (checkoutApplied), so the two can never disagree.
func (p PendingWrite) retained() bool {
switch {
case p.Kind == PendingWriteRequestRecord || p.CommitRequest != nil:
return true
case p.Kind == PendingWriteResync:
return p.resyncActions == nil || !p.resyncActions.empty()
default:
return len(p.Events) > 0
}
}
// keepsEveryObject reports whether a write keeps the objects whose plan changed nothing: only a
// save does (docs/design/applier-echoes.md, decision 9).
func (p PendingWrite) keepsEveryObject() bool { return p.CommitRequest != nil || p.AllowEmpty }
func (p PendingWrite) path() string {
if targetPath := p.Target().Path; targetPath != "" {
return targetPath
}
for _, event := range p.Events {
if event.Path != "" {
return event.Path
}
}
return ""
}
// messageResolution is the single decision about where one write's commit message comes from.
// commitMetadata renders by it and commits_total is labelled by it, so the metric cannot report a
// source the renderer did not use.
type messageResolution int
const (
// messageResolutionUnsupported is a write whose kind has no message path at all.
messageResolutionUnsupported messageResolution = iota
// messageResolutionPreRendered is a resync, which renders the target's reconcile template
// itself and hands the result over as the write's message. That is generated text, not a
// request's override, so the CommitRequest literal contract does not bind it: an operator's
// reconcile template may legitimately be long or contain a tab.
messageResolutionPreRendered
// messageResolutionRequest is a message a CommitRequest supplied, used verbatim.
messageResolutionRequest
// messageResolutionRequestTemplate is a CommitRequest's message framed by the target's
// requestTemplate. The request's own bytes are never parsed — they ride in as .RequestMessage —
// so the formatting policy is the operator's while the message stays the requester's.
messageResolutionRequestTemplate
// messageResolutionRequestFallback is a requestTemplate that failed to render, so the literal
// was committed instead. Never returned by resolveMessage: it is decided at render time and
// stamped onto the write, because a silent, SUCCESSFUL commit is otherwise invisible.
messageResolutionRequestFallback
// messageResolutionLiveTemplate renders liveTemplate for a live window.
messageResolutionLiveTemplate
)
// resolveMessage classifies where this write's message comes from. It states the precedence
// once; every caller reads the result rather than re-testing the conditions.
func (p PendingWrite) resolveMessage() messageResolution {
switch {
case p.Kind == PendingWriteResync:
return messageResolutionPreRendered
// Framing is bound to the GROUPED kind, not merely to "carries a message". A CommitRequest
// attaches its message to an open live window, which finalizes as PendingWriteCommit, so that
// is the only write requestTemplate describes.
case p.Kind == PendingWriteCommit &&
p.CommitMessage != "" &&
p.CommitConfig.Message.RequestTemplate != "":
return messageResolutionRequestTemplate
case p.CommitMessage != "":
return messageResolutionRequest
case p.Kind == PendingWriteCommit:
return messageResolutionLiveTemplate
default:
return messageResolutionUnsupported
}
}
// label is the commits_total `message_source` value for this resolution.
func (r messageResolution) label() string {
switch r {
case messageResolutionRequest:
return messageSourceCommitRequest
case messageResolutionRequestTemplate:
return messageSourceCommitRequestFramed
case messageResolutionRequestFallback:
return messageSourceCommitRequestFallback
case messageResolutionPreRendered:
return messageSourceReconcile
case messageResolutionLiveTemplate:
return messageSourceLive
case messageResolutionUnsupported:
// An unsupported kind fails to render, so it creates no commit and never reaches the
// counter. Name it rather than folding it into a real source if that ever changes.
return messageSourceUnknown
default:
return messageSourceUnknown
}
}
// messageSource is the commits_total `message_source` label for this write.
//
// It prefers the source stamped at commit time, because that is the only one that knows whether a
// requestTemplate actually rendered. An unstamped write — one that never reached the executor, as
// in a unit test — falls back to what the write itself implies.
func (p PendingWrite) messageSource() string {
if p.committedMessageSource != messageResolutionUnsupported {
return p.committedMessageSource.label()
}
return p.resolveMessage().label()
}
// commitMetadata builds one commit's message and options, and reports the message source it
// actually committed under — which is not always what resolveMessage predicted, because a
// requestTemplate that fails to render falls back to the literal. The caller stamps that back onto
// the write so the commits counter tells the truth; see executePendingWrites.
func (p PendingWrite) commitMetadata() (string, *gogit.CommitOptions, messageResolution, error) {
var message string
var err error
resolution := p.resolveMessage()
switch resolution {
case messageResolutionPreRendered:
message = p.CommitMessage
case messageResolutionRequest:
message = p.CommitMessage
err = ValidateLiteralCommitMessage(message)
case messageResolutionRequestTemplate:
// Validated on THIS arm too, not just the verbatim one. The literal reaches Git either
// way — framed on success, and by itself on the fallback below — so framing must not
// become a route that smuggles a message past the check the verbatim path enforces.
// The controller validates earlier (commitrequest_controller.go), which makes this
// belt-and-braces rather than reachable today; it is exactly the kind of invariant that
// stops being true once a second producer of PendingWrite appears.
if err = ValidateLiteralCommitMessage(p.CommitMessage); err == nil {
message, resolution = p.framedRequestMessage()
}
case messageResolutionLiveTemplate:
message, err = renderLiveCommitMessage(p, p.CommitConfig)
case messageResolutionRequestFallback:
// resolveMessage never returns it; only framedRequestMessage produces it, below.
err = fmt.Errorf("unsupported pending write kind %q", p.Kind)
case messageResolutionUnsupported:
err = fmt.Errorf("unsupported pending write kind %q", p.Kind)
default:
err = fmt.Errorf("unsupported pending write kind %q", p.Kind)
}
if err != nil {
return "", nil, messageResolutionUnsupported, err
}
log.Log.V(1).Info("Selected commit message", "source", resolution.label())
return message, commitOptionsFor(p, p.CommitConfig, p.Signer, time.Now()), resolution, nil
}
// framedRequestMessage renders the target's requestTemplate around an attached CommitRequest's
// message, falling back to that message verbatim if the render fails.
//
// The fallback deviates from liveTemplate, where a render failure fails the write, and the
// deviation is the point: here a correct, non-lossy answer is always in hand, because the literal
// already passed ValidateLiteralCommitMessage at admission. Failing would discard the requester's
// save AND every other author's retained events in the same window, to punish a formatting mistake
// that ValidateCommitConfig should have caught at GitTarget admission and that the fallback makes
// harmless.
//
// It is never silent. The Error log names the target, and the returned resolution moves the commit
// to message_source="commit_request_fallback" — because a fallback is a SUCCESSFUL commit, so no
// condition moves, nothing is refused, and a rate is the only way an operator sees a template that
// has quietly stopped applying.
//
// It returns no error, and cannot: "this failed" is not an outcome here, which is the whole point.
func (p PendingWrite) framedRequestMessage() (string, messageResolution) {
framed, err := renderRequestCommitMessage(p, p.CommitConfig)
if err != nil {
log.Log.Error(err, "requestTemplate failed to render; committing the CommitRequest message verbatim",
"gitTarget", p.Target().Namespace+"/"+p.Target().Name)
return p.CommitMessage, messageResolutionRequestFallback
}
return framed, messageResolutionRequestTemplate
}
// executePendingWrite commits one write. Its first execution decides its actions on the tree it is
// committed on, and records them on the write (actionsDecided); every later one, a replay, applies
// those actions.
func (w *BranchWorker) executePendingWrite(
ctx context.Context,
repo *gogit.Repository,
worktree *gogit.Worktree,
pendingWrite *PendingWrite,
) (int, plumbing.Hash, messageResolution, error) {
switch pendingWrite.Kind {
case PendingWriteRequestRecord:
return w.executeRequestRecord(ctx, worktree, *pendingWrite)
case PendingWriteResync:
// Resync writes never carry a CommitRequest, so their commit hash is unused;
// report ZeroHash to keep the per-write SHA bookkeeping uniform.
created, err := w.executeResyncPendingWrite(ctx, repo, worktree, pendingWrite)
return created, plumbing.ZeroHash, messageResolutionPreRendered, err
case PendingWriteCommit:
return w.executeEventWrite(ctx, repo, worktree, pendingWrite)
default:
return 0, plumbing.ZeroHash, messageResolutionUnsupported,
fmt.Errorf("unsupported pending write kind %q", pendingWrite.Kind)
}
}
// executeEventWrite applies a grouped window's events and commits them. The
// first time, the write keeps only the events whose plan changed the tree: an object that changed
// nothing is no action, and a replay must not plan it again onto a newer tip, where it would revert
// what that tip changed (docs/design/applier-echoes.md, F2). A save keeps every object.
func (w *BranchWorker) executeEventWrite(
ctx context.Context,
repo *gogit.Repository,
worktree *gogit.Worktree,
pendingWrite *PendingWrite,
) (int, plumbing.Hash, messageResolution, error) {
if len(pendingWrite.Events) == 0 {
return 0, plumbing.ZeroHash, messageResolutionUnsupported, nil
}
target := pendingWrite.Target()
encryptionPath := filepath.Join(worktree.Filesystem().Root(), sanitizePath(pendingWrite.path()))
if err := configureSecretEncryptionWriter(
w.contentWriter,
encryptionPath,
target.EncryptionConfig,
); err != nil {
return 0, plumbing.ZeroHash, messageResolutionUnsupported,
fmt.Errorf("configure secret encryptor: %w", err)
}
anyChanges, changedBy, err := w.planPendingWriteEvents(
ctx, repo, worktree, pendingWrite.Events, pendingWrite.Targets)
if err != nil {
return 0, plumbing.ZeroHash, messageResolutionUnsupported, err
}
if !pendingWrite.actionsDecided {
pendingWrite.actionsDecided = true
if !pendingWrite.keepsEveryObject() {
pendingWrite.Events = eventsThatChanged(pendingWrite.Events, changedBy)
}
}
recordEmpty := !anyChanges && pendingWrite.AllowEmpty && !target.Suspend
if !anyChanges && !recordEmpty {
return 0, plumbing.ZeroHash, messageResolutionUnsupported, nil
}
commitMessage, commitOptions, source, err := pendingWrite.commitMetadata()
if err != nil {
return 0, plumbing.ZeroHash, messageResolutionUnsupported, err
}
// A window whose writes changed nothing still commits when its CommitRequest asked to record
// its message: whenNothingToCommit: CommitEmpty.
commitOptions.AllowEmptyCommits = recordEmpty
hash, err := worktree.Commit(commitMessage, commitOptions)
if err != nil {
return 0, plumbing.ZeroHash, messageResolutionUnsupported,
fmt.Errorf("failed to create commit: %w", err)
}
log.FromContext(ctx).Info(
"git commit created",
"events",
len(pendingWrite.Events),
"message",
commitMessage,
)
return 1, hash, source, nil
}
// eventsThatChanged is the events whose plan changed the tree, in order: events itself when every
// one did.
func eventsThatChanged(events []Event, changedBy []bool) []Event {
if !slices.Contains(changedBy, false) {
return events
}
kept := make([]Event, 0, len(events))
for i := range events {
if changedBy[i] {
kept = append(kept, events[i])
}
}
return kept
}
// planPendingWriteEvents plans a write's events onto the worktree, one GitTarget base at a time, and
// reports whether the tree changed and, per event and in order, whether that event changed it. An
// event of a suspended target changes nothing.
func (w *BranchWorker) planPendingWriteEvents(
ctx context.Context,
repo *gogit.Repository,
worktree *gogit.Worktree,
events []Event,
targets map[pendingTargetKey]ResolvedTargetMetadata,
) (bool, []bool, error) {
// Plan-then-flush each GitTarget subtree once: build the structure model, resolve
// every event to a single-identity action, apply to hydrated file buffers, and
// flush dirty/deleted files. A grouped window is single-target, so this is usually
// one base path.
byBase, indexes := groupEventsByBaseWithIndex(events)
changedBy := make([]bool, len(events))
anyChanges := false
for _, base := range sortedBaseKeys(byBase) {
// A suspended target scans and publishes what it resolved, and writes nothing. The scan
// is not skipped with the write: dropping it would leave status.placement frozen at
// whatever the folder looked like when suspension began, which is exactly when a stale
// answer costs the most. Its events are dropped rather than deferred — resuming replays
// the cluster's current state on the next resync, not a backlog of stale intermediate
// ones.
if md, ok := targetForBase(targets, base); ok && md.Suspend {
if err := w.refuseUnsafeWorktree(ctx, worktree, base, md); err != nil {
return false, nil, err
}
log.FromContext(ctx).V(1).Info("live write suppressed: GitTarget is suspended",
"gitTarget", md.Namespace+"/"+md.Name, "path", base, "events", len(byBase[base]))
continue
}
// Stage this path's bootstrap files before any resource write into it.
//
// It is INSIDE the loop, and after the suspend gate, because both matter. Bootstrap
// staging writes .gittargetignore (and .sops.yaml) into the path and adds them to the
// index, and the index is shared by every target on this branch — so staging for a
// suspended path meant the next active target's commit carried those files into the
// suspended target's folder. A suspended target must leave no trace in a commit,
// including one it did not author. The resync path already had this ordering.
for _, event := range byBase[base] {
if err := ensureBootstrapTemplateInPath(repo, base, event.BootstrapOptions); err != nil {
return false, nil, err
}
}
changed, baseChangedBy, err := w.planEventsToWorktree(
ctx,
worktree,
base,
byBase[base],
placementPolicyForBase(targets, base),
namespacePolicyForBase(targets, base),
pruneModeForBase(targets, base),
)
if err != nil {
return false, nil, err
}
if changed {
anyChanges = true
}
for j, i := range indexes[base] {
changedBy[i] = baseChangedBy[j]
}
}
return anyChanges, changedBy, nil
}
// executeRequestRecord commits a CommitRequest's message with an untouched tree: the record a
// request that ran out of time to attach leaves when it asked for whenNothingToCommit: CommitEmpty.
func (w *BranchWorker) executeRequestRecord(
ctx context.Context,
worktree *gogit.Worktree,
pendingWrite PendingWrite,
) (int, plumbing.Hash, messageResolution, error) {
message, options, source, err := pendingWrite.commitMetadata()
if err != nil {
return 0, plumbing.ZeroHash, messageResolutionUnsupported, err
}
options.AllowEmptyCommits = true
hash, err := worktree.Commit(message, options)
if err != nil {
return 0, plumbing.ZeroHash, messageResolutionUnsupported,
fmt.Errorf("failed to create request record commit: %w", err)
}
log.FromContext(ctx).Info("Empty commit created to record a CommitRequest that found nothing to commit",
"gitTarget", pendingWrite.GitTargetNamespace+"/"+pendingWrite.GitTargetName,
"branch", w.Branch, "commit", hash.String())
return 1, hash, source, nil
}
// mayCommitEmpty reports whether this write is allowed to produce an empty commit, and so whether
// its commit is worth inspecting for one.
func (p PendingWrite) mayCommitEmpty() bool {
return p.Kind == PendingWriteRequestRecord || p.AllowEmpty
}
// isEmptyCommit reports whether a commit changed no file: its tree is its first parent's. A zero
// hash made no commit at all. A root commit has no parent to compare with, so it is empty when its
// tree holds no file — the empty commit CommitEmpty makes on a repository with no history yet.
func isEmptyCommit(repo *gogit.Repository, hash plumbing.Hash) bool {
if hash.IsZero() {
return false
}
commit, err := repo.CommitObject(hash)
if err != nil {
return false
}
if commit.NumParents() == 0 {
tree, err := commit.Tree()
return err == nil && len(tree.Entries) == 0
}
parent, err := commit.Parent(0)
if err != nil {
return false
}
return parent.TreeHash == commit.TreeHash
}