Artifact da65a2bdd21fcac6bdef9e32688899c3fe164c0fb91c323a478d96cae442255c:
- File
leaf/agent/code_artifact.go
— part of check-in
[fabb4e3453]
at
2026-05-13 00:13:29
on branch trunk
— refactor(agent): drop mergeWithParent now that libfossil v0.6.2 ships the fix (#165)
libfossil#30 / EdgeSync#163 shipped consumer-side and upstream respectively. With libfossil v0.6.2 (PR #32, commit e6d34fb), Repo.Commit merges parent F-cards with supplied Files internally when ParentID > 0 — the exact behavior agent.mergeWithParent was emulating. Drop the duplicate.
Repo policy (CLAUDE.md "Upstream-fix policy") is unambiguous: when a fix lands in a repo we own, the workaround in the consuming repo should be removed, not left as defense-in-depth. libfossil is ours; a regression there gets fixed there.
Bumps: - root go.mod: v0.6.1 → v0.6.2 - leaf/go.mod: v0.5.0 → v0.6.2 (was lagging; auto-bump workflow has been failing per #146/#158) - bridge/go.mod: v0.5.0 → v0.6.2 (same)
Keeps the regression tests TestAgent_Commit_PreservesParentFiles and TestAgent_Commit_SuppliedFileOverridesParent — they assert behavior, not implementation, and now serve as integration coverage so any future libfossil regression on full-tree manifest emission surfaces in EdgeSync CI too.
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> (user: [email protected], size: 10051) [annotate] [blame] [check-ins using]
package agent import ( "context" "database/sql" "errors" "fmt" "strings" libfossil "github.com/danmestas/libfossil" ) // RevID is an opaque commit handle. Treat as opaque; do not parse. type RevID string // FileToCommit names a file and its content for inclusion in a commit. type FileToCommit struct { Name string Content []byte } // CommitOpts configures Agent.Commit. type CommitOpts struct { Files []FileToCommit Message string Author string // fossil user; required // ParentID is the primary parent rid for the new commit. When 0, // Agent.Commit resolves the current trunk tip and uses that — the // natural "chain onto trunk" behavior matching `fossil ci`. On a // fresh repo with no trunk tip, the resolution falls through to 0 // and the commit is a legitimate orphan root (the first commit). // Set explicitly to chain onto a non-trunk branch via Agent.Tip. ParentID int64 // MergeParents lists additional parents for a merge commit. Each // contributes a secondary entry on the manifest's P-card. MergeParents []int64 // Tags, when non-empty, are attached to the resulting checkin manifest. // Tags are libfossil's primitive — fossil "branches" are themselves // propagating "branch=<name>" + "sym-<name>" tag pairs. To land a commit // on branch "agent/abc123": // // Tags: []TagSpec{ // {Name: "branch", Value: "agent/abc123"}, // {Name: "sym-agent/abc123", Value: "*"}, // } // // Empty preserves current behavior (commit to current branch, no extra tags). Tags []TagSpec } // TagSpec describes a tag attached at commit time. Mirrors libfossil's // underlying tag primitive; pass these via CommitOpts.Tags to attach // arbitrary tags (including the propagating branch-tag pair that creates // or advances a fossil branch) on the resulting checkin. type TagSpec struct { Name string Value string } // SyncOpts configures Agent.SyncTo. type SyncOpts struct { Push bool Pull bool User string Password string ProjectCode string // optional; auto-derived from agent's repo if empty } // SyncResult summarizes a sync session. type SyncResult struct { Pushed int Pulled int Forked bool BranchID string // local fork tip's UUID; empty if no fork } // Commit commits a set of files to the agent's repo with the given message // and author. Returns the new manifest UUID as an opaque RevID. // // opts.Files reads with `fossil ci` semantics — "files this commit // changes." Files at the parent that are not in opts.Files are carried // forward unchanged. libfossil.Repo.Commit handles the merge against // the parent's F-card set internally as of v0.6.2 (the fix for // libfossil#30 / EdgeSync#152); the agent layer is a thin pass-through. // // When opts.ParentID is 0, the current trunk tip is resolved and used as // the parent — matching `fossil ci`'s default chain-onto-tip behavior. On // an empty repo (no trunk tip yet), the resolution returns 0 and the // commit is a legitimate orphan root. func (a *Agent) Commit(ctx context.Context, opts CommitOpts) (RevID, error) { if opts.Author == "" { return "", errors.New("agent: Commit: Author is required") } parentID, err := a.resolveCommitParent(opts.ParentID) if err != nil { return "", err } files := make([]libfossil.FileToCommit, len(opts.Files)) for i, f := range opts.Files { files[i] = libfossil.FileToCommit{Name: f.Name, Content: f.Content} } var tags []libfossil.TagSpec if len(opts.Tags) > 0 { tags = make([]libfossil.TagSpec, len(opts.Tags)) for i, t := range opts.Tags { tags[i] = libfossil.TagSpec{Name: t.Name, Value: t.Value} } } _, uuid, err := a.repo.Commit(libfossil.CommitOpts{ Files: files, Comment: opts.Message, User: opts.Author, ParentID: parentID, MergeParents: opts.MergeParents, Tags: tags, }) if err != nil { return "", fmt.Errorf("agent: commit: %w", err) } return RevID(uuid), nil } // resolveCommitParent maps the caller-supplied ParentID to the rid the // commit should chain onto. Non-zero is used verbatim. Zero auto-resolves // trunk's tip; on an empty repo with no trunk tip yet, returns 0 (which // libfossil treats as orphan root — correct first-commit behavior). func (a *Agent) resolveCommitParent(supplied int64) (int64, error) { if supplied != 0 { return supplied, nil } rid, err := a.repo.BranchTip("trunk") if err != nil { if errors.Is(err, sql.ErrNoRows) || strings.Contains(err.Error(), "no rows in result set") { return 0, nil } return 0, fmt.Errorf("agent: commit: resolve trunk tip: %w", err) } return rid, nil } // Sync runs one sync round against the agent's configured upstream/transport. // Returns nil result if no transport is configured. func (a *Agent) Sync(ctx context.Context) (*SyncResult, error) { if a.transport == nil { return nil, errors.New("agent: Sync: no transport configured") } res, err := a.repo.Sync(ctx, a.transport, a.buildSyncOpts()) if err != nil { return nil, fmt.Errorf("agent: sync: %w", err) } return a.toSyncResult(res), nil } // SyncTo runs one sync round against an explicit hub URL. The transport is // constructed internally; callers do not see libfossil.Transport. func (a *Agent) SyncTo(ctx context.Context, hubURL string, opts SyncOpts) (*SyncResult, error) { if hubURL == "" { return nil, errors.New("agent: SyncTo: hubURL is required") } transport := libfossil.NewHTTPTransport(hubURL) projectCode := opts.ProjectCode if projectCode == "" { projectCode = a.projectCode } res, err := a.repo.Sync(ctx, transport, libfossil.SyncOpts{ Push: opts.Push, Pull: opts.Pull, ProjectCode: projectCode, User: opts.User, Password: opts.Password, }) if err != nil { return nil, fmt.Errorf("agent: syncTo %s: %w", hubURL, err) } return a.toSyncResult(res), nil } // Read returns the contents of path at the current trunk tip. func (a *Agent) Read(ctx context.Context, path string) ([]byte, error) { return a.repo.ReadFileAt("trunk", path) } // Files lists the file names at the current trunk tip. An empty repo (no // checkins on trunk) returns (nil, nil). func (a *Agent) Files(ctx context.Context) ([]string, error) { rid, err := a.repo.BranchTip("trunk") if err != nil { return nil, fmt.Errorf("agent: files: %w", err) } if rid == 0 { return nil, nil } entries, err := a.repo.ListFiles(rid) if err != nil { return nil, fmt.Errorf("agent: files: %w", err) } names := make([]string, len(entries)) for i, e := range entries { names[i] = e.Name } return names, nil } // Diff returns the unified diff between revA and revB, concatenated across // all changed files. Either rev may be a branch name, tag, or RevID. func (a *Agent) Diff(ctx context.Context, revA, revB RevID) ([]byte, error) { ridA, err := a.repo.ResolveVersion(string(revA)) if err != nil { return nil, fmt.Errorf("agent: diff: resolve %q: %w", revA, err) } ridB, err := a.repo.ResolveVersion(string(revB)) if err != nil { return nil, fmt.Errorf("agent: diff: resolve %q: %w", revB, err) } entries, err := a.repo.Diff(ridA, ridB, "") if err != nil { return nil, fmt.Errorf("agent: diff: %w", err) } var out strings.Builder for _, e := range entries { out.WriteString(e.Unified) } return []byte(out.String()), nil } // Tip returns the manifest UUID at the tip of the named branch. An unknown // or empty branch (no checkins) returns "" with nil error; an error // indicates a substrate fault, not an empty branch. func (a *Agent) Tip(ctx context.Context, branch string) (RevID, error) { rid, err := a.repo.BranchTip(branch) if err != nil { if errors.Is(err, sql.ErrNoRows) || strings.Contains(err.Error(), "no rows in result set") { return "", nil } return "", fmt.Errorf("agent: tip %q: %w", branch, err) } if rid == 0 { return "", nil } uuid, err := a.repo.UUIDFromRID(rid) if err != nil { return "", fmt.Errorf("agent: tip %q: %w", branch, err) } return RevID(uuid), nil } // ExtractTo populates dir with the files at rev. dir must already exist. // rev may be a branch name, tag, or RevID returned from Commit. Empty rev // is a no-op. func (a *Agent) ExtractTo(ctx context.Context, dir string, rev RevID) error { if rev == "" { return nil } rid, err := a.repo.ResolveVersion(string(rev)) if err != nil { return fmt.Errorf("agent: extractTo %q: resolve %q: %w", dir, rev, err) } co, err := a.repo.CreateCheckout(dir, libfossil.CheckoutCreateOpts{}) if err != nil { return fmt.Errorf("agent: extractTo %q: create checkout: %w", dir, err) } defer co.Close() if err := co.Extract(rid, libfossil.ExtractOpts{}); err != nil { return fmt.Errorf("agent: extractTo %q: extract: %w", dir, err) } return nil } // Config reads a fossil config value (e.g. "project-code"). func (a *Agent) Config(key string) (string, error) { return a.repo.Config(key) } func (a *Agent) toSyncResult(r *libfossil.SyncResult) *SyncResult { out := &SyncResult{} if r != nil { out.Pushed = r.FilesSent out.Pulled = r.FilesRecvd } if forks, err := a.repo.DetectForks(); err == nil && len(forks) > 0 { out.Forked = true if uuid, uerr := a.repo.UUIDFromRID(forks[0].LocalTip); uerr == nil { out.BranchID = uuid } } return out } // applySQLiteTuning applies SQLite settings the agent needs for safe // concurrent operation against its repo. The busy_timeout PRAGMA tells // SQLite to retry on SQLITE_BUSY for up to 30s before giving up, which // is what production callers want under contention. // // We do NOT cap MaxOpenConns at 1 here, even though libfossil PRAGMAs // are per-connection. Capping the pool to 1 deadlocks libfossil's clone // path, which has internal goroutines that all need a DB connection // (issue #120). If PRAGMA stickiness across pooled connections matters // later, route it through the modernc driver's per-connection init // hook rather than a pool cap. func applySQLiteTuning(r *libfossil.Repo) { _, _ = r.DB().Exec(`PRAGMA busy_timeout = 30000`) }