const StateOpen, StateMerged, StateClosed
Change states.
Package forge is the domain engine of an on-chain software forge: repos, roles, an append-only reference log, issues ...
The domain engine of an on-chain software forge: repos, roles, an append-only
reference log, issues, change requests and reviews. Pure gno, no chain imports,
no realm globals. The realm that wires it to gno.land is
gno.land/r/moul/forge/v0.
It does not store code. Git objects stay in git, behind whatever mirror a repo declares (an https remote, an IPFS CID, a peer). On gno.land a realm write locks a storage deposit of 100ugnot per byte, so a 1 MB repository would cost about 100 GNOT to park on chain and more on every push. Anchoring is the only shape that survives contact with real repositories.
What it does store is the part a forge is actually trusted for, and that git alone does not authenticate:
Every ref move appends a LogEntry: ref name, old object, new object, actor,
block height, kind (create, update, force, delete, merge), an optional
note, and a Digest committing to the previous entry's digest. Publish
LogHead() anywhere off chain and the entire history of every ref becomes
falsifiable. VerifyLog() recomputes the chain; a client should run the same
computation over the values it read back, since a transparency log nobody
verifies is just a log.
Moves are compare-and-swap:
1r.SetRef(actor, height, "refs/heads/main", expectedOID, newOID, "ship it")
expectedOID is the tip the caller last saw, empty to create the ref. A stale
expectation returns ErrStaleRef instead of overwriting. That is git's
--force-with-lease, except the lease is held by consensus rather than by the
server you are pushing to. ForceSetRef skips the expectation, needs
RoleMaintainer, and is permanently recorded as KindForce: a force-push is
not forbidden here, it is made impossible to hide.
The chain has no objects, so it cannot check that a new tip descends from the old one, and this package does not pretend otherwise. Ordering, attribution and policy are on chain; ancestry is verified by a client that holds the repo. This is the same split as gittuf's reference state log, with the log moved out of the repository and into a place no maintainer can rewrite.
RoleNone < RoleReader < RoleWriter < RoleMaintainer < RoleAdmin < RoleOwner,
totally ordered so every check is one comparison. Writers move refs, maintainers
force and merge, admins manage members and policy. The last owner cannot be
demoted. Anyone can open an issue or a change request without a role: the spam
gate is that the author pays gas and locks the deposit for their own bytes.
A Review names the object id it reviewed, not the change. Push a new head and
every earlier approval stops counting, because it approved something that is no
longer what would be merged. Nothing has to remember to dismiss it, and no
setting can turn the behaviour off. Only a writer's approval counts toward
RequiredApprovals; anyone else's review is signal, not authority. A
request-changes verdict from a writer blocks the merge while it stands.
MergeChange is a compare-and-swap on the target ref plus a policy check, and
it writes a KindMerge entry naming the change it came from.
Errors, never panics: this package is pure, so a realm turns an error into an
abort (the only way to revert state in gno) and a test asserts on the value.
Every collection is an avl.Tree, so every listing is ordered and paginatable,
and no iteration walks unbounded state. The caller supplies the actor address
and the block height, which is what makes the whole engine unit-testable with no
chain at all.
1f := forge.New()
2r, _ := f.CreateRepo(alice, height, "moul/forge", "an on-chain forge", "")
3r.SetMember(alice, bob, forge.RoleMaintainer)
4r.SetRef(alice, height, "refs/heads/main", "", oid, "initial import")
5c, _ := r.OpenChange(carol, height, "title", "body", "", "refs/heads/feat", head, "refs/heads/main")
6r.ReviewChange(bob, height, c.ID, forge.VerdictApprove, "lgtm")
7r.MergeChange(bob, height, c.ID, oid, merged, "merge change 0")
Every stored string is bounded (see the Max* constants) because an unbounded
field is an unbounded deposit. Ref names are a refs/-rooted subset of
git-check-ref-format; object ids are 40 or 64 lowercase hex characters; repo ids
are <namespace>/<name>, where the name is a lowercase slug and the namespace is
either a slug (a claimed user name) or a bech32 address. The two shapes cannot
collide: an address is 40 characters and a slug caps at 39.
This package validates the shape of a namespace and nothing else. Whether a
caller may claim one is an ownership question that needs a chain, so it lives in
the realm: a name must be held in r/sys/users, an address must be the caller's
own.
One economic rule shows up in the API: deleting is privileged. On gno.land the
storage-deposit refund goes to whoever frees the bytes, not to whoever paid for
them, so an open delete path pays for vandalism. DeleteRef needs
RoleMaintainer, and issues, comments and reviews have no delete at all.
Part of moul/gno-contracts — moul's versioned gno.land contracts. See the repository for the full catalog, build/test tooling, and usage.
Dependency graph:

⚠️ Disclaimer: provided as-is, without warranty; not security-audited. Full disclaimer: DISCLAIMER.
Package forge is the domain engine of an on-chain software forge: repos, roles, an append-only reference log, issues and change requests (pull requests), with no chain imports of its own.
What it does NOT do, on purpose: store blobs, trees or packfiles. Git objects stay wherever git already puts them (a mirror, an IPFS CID, a peer) and this package records what a forge is actually trusted for and what git alone does not authenticate:
The chain cannot see the object graph, so it cannot verify that a new tip descends from the old one. It does not pretend to: every ref move is a compare-and-swap against the tip the caller expected (git's --force-with-lease, moved somewhere the forge operator cannot rewrite), any move that abandons that discipline is recorded as a force, and ancestry is checked by a client that has the objects. Ordering, attribution and policy are on chain; proof is local.
All state lives in avl trees so every listing is ordered and paginatable, and every mutation takes the actor and the block height from the caller: the package is pure, deterministic and unit-testable without a chain.
Live demo: gno.land/r/moul/forge/v0.
Change states.
Review verdicts.
1const (
2 KindCreate = "create" // a ref that did not exist now points somewhere
3 KindUpdate = "update" // compare-and-swap succeeded
4 KindForce = "force" // the tip was replaced without a matching expectation
5 KindDelete = "delete" // the ref is gone (the log is not)
6 KindMerge = "merge" // an update performed by merging a change request
7)Log entry kinds.
1const (
2 MaxRepoPartLen = 39 // per side of "<namespace>/<name>"
3 MaxRefNameLen = 255 // git's own limit for a single ref name
4 MaxTitleLen = 200 // issue / change title
5 MaxBodyLen = 8192 // issue / change body
6 MaxCommentLen = 4096
7 MaxDescLen = 512
8 MaxNoteLen = 140 // single-line note attached to a log entry
9 MaxMirrorLen = 512
10 MaxMirrors = 8
11 MaxLabels = 10
12 MaxLabelLen = 32
13)Size caps. Every string the chain stores is bounded: an unbounded field is an unbounded storage deposit, and on gno.land the deposit is paid per byte by whoever writes it (100ugnot/byte at the time of writing).
1const (
2 RoleNone Role = iota // not a member
3 RoleReader // explicit read (all repos are public in v0)
4 RoleWriter // move non-protected refs, update own changes
5 RoleMaintainer // force-move refs, merge changes, triage issues
6 RoleAdmin // manage members and repo settings
7 RoleOwner // admin + transfer; at least one always exists
8) 1var (
2 ErrInvalidRepoID = errors.New("forge: invalid repo id")
3 ErrInvalidRefName = errors.New("forge: invalid ref name")
4 ErrInvalidOID = errors.New("forge: invalid object id")
5 ErrInvalidText = errors.New("forge: invalid text")
6 ErrInvalidRole = errors.New("forge: invalid role")
7 ErrInvalidVerdict = errors.New("forge: invalid review verdict")
8 ErrInvalidMirror = errors.New("forge: invalid mirror locator")
9 ErrTooLong = errors.New("forge: value too long")
10 ErrTooMany = errors.New("forge: too many entries")
11 ErrRepoExists = errors.New("forge: repo already exists")
12 ErrRepoNotFound = errors.New("forge: repo not found")
13 ErrRepoArchived = errors.New("forge: repo is archived")
14 ErrRefNotFound = errors.New("forge: ref not found")
15 ErrRefExists = errors.New("forge: ref already exists")
16 ErrStaleRef = errors.New("forge: stale ref (compare-and-swap failed)")
17 ErrUnauthorized = errors.New("forge: unauthorized")
18 ErrIssueNotFound = errors.New("forge: issue not found")
19 ErrIssueClosed = errors.New("forge: issue is closed")
20 ErrChangeNotFound = errors.New("forge: change not found")
21 ErrChangeNotOpen = errors.New("forge: change is not open")
22 ErrSelfApproval = errors.New("forge: self-approval is not allowed")
23 ErrNotEnoughApproval = errors.New("forge: not enough approvals")
24 ErrChangesRequested = errors.New("forge: changes requested by a reviewer")
25 ErrSameOID = errors.New("forge: ref already points at that object")
26 ErrLastOwner = errors.New("forge: cannot demote the last owner")
27)Stable, machine-readable error values. Callers (realms, clients, indexers) should switch on these rather than on message text: a realm turns them into panics, and the panic string is the only thing a user sees.
AddressNamespace reports whether ns is shaped like a gno bech32 address, the namespace every account owns without registering anything. The realm still checks that it is the CALLER's address; this only says which of the two ownership rules applies.
EntryDigest computes the chain digest of e given the previous entry's digest. It is exported so an off-chain verifier can recompute the chain byte for byte from the values it read back; the field order below is the wire format and must not change within a version.
SplitRepoID splits "<namespace>/<name>" into its two halves. It does not validate either half; ok is false only when the id is not two slash-separated non-empty parts.
ValidLabel reports whether s is an issue label.
ValidLine reports whether s is single-line text within max bytes. Used for titles and for log-entry notes, which are fields of the digest chain: a newline there would let one note impersonate two.
ValidMirror reports whether s looks like a fetch locator. The chain does not resolve it: it only records where the maintainers say the objects are: so the check is a shape check, not a promise that anything is reachable.
ValidOID reports whether s is a git object id: 40 (SHA-1) or 64 (SHA-256) lowercase hex characters. Case is fixed so the same object always has the same on-chain key and the same digest-chain input.
ValidRefName reports whether s is a fully-qualified ref name this forge accepts: a "refs/"-rooted subset of git-check-ref-format(1).
Deliberately stricter than git: the name must be fully qualified, so there is never an ambiguity between "main" the branch and "main" the tag, and a client can map an on-chain name onto a local ref without a lookup table.
ValidRepoID reports whether s is "<namespace>/<name>". The name is always a lowercase slug; the namespace is either a slug (a claimed user name) or a bech32 address (the caller's own). The two shapes cannot collide: an address is 40 characters and a slug caps at MaxRepoPartLen, which is 39.
This layer validates the SHAPE only. Whether the caller may claim a given namespace is an ownership question that needs a chain, so it belongs to the realm (see the realm's README).
ValidText reports whether s fits in max bytes and carries no control characters other than newline and tab. Render output is markdown served by gnoweb, so a stray control byte is a rendering bug for every reader forever.
New returns an empty forge.
ParseRole is the inverse of Role.String.
1type Change struct {
2 ID int64
3 Title string
4 Body string
5 Author address
6 SourceRepo string // forge repo id, or a mirror locator; "" means this repo
7 SourceRef string
8 HeadOID string
9 TargetRef string
10 State string
11 CreatedAt int64
12 UpdatedAt int64
13
14 MergedOID string // the object TargetRef moved to
15 MergedBy address
16 MergedAt int64
17
18 reviews *avl.Tree // address string -> *Review (latest per reviewer)
19 comments *avl.Tree // padded id -> *Comment
20 nextComment int64
21}Change is a change request (a pull request): a claim that TargetRef should be moved to include HeadOID, plus the reviews of that claim.
Reviews are bound to the object id they reviewed, not to the change. Push a new head and every earlier approval stops counting: not by a policy toggle a maintainer can switch off, but because the approval names an object that is no longer what is being merged.
CommentCount is the number of replies on the change.
IterateComments walks replies oldest-first.
IterateReviews walks reviews in reviewer-address order.
Review returns a reviewer's latest verdict, or nil.
ReviewCount is the number of reviewers who have weighed in (latest verdict per reviewer, on any head).
Stale reports whether a review no longer applies to the change's head.
Comment is one reply, on an issue or on a change request.
Forge is the top-level registry: repo id -> repo.
1func (f *Forge) CreateRepo(actor address, height int64, id, description, defaultRef string) (*Repo, error)CreateRepo registers a repo owned by actor.
Fork registers newID as a fork of srcID and copies the parent's current refs into the child's log, so the fork records exactly what it forked from. The objects are not copied: they never were on chain: so the child inherits the parent's mirrors as its initial fetch locators.
HasRepo reports whether the id is taken.
IterateNamespace walks the repos of one namespace in id order.
IterateRepos walks repos in id order, newest-last, and stops when cb returns true. offset/count page the walk; count <= 0 means "to the end".
Repo returns the repo, or nil.
Size is the number of repos.
Issue is a discussion thread bound to a repo. Anyone with an address may open one: the spam gate is not a moderator, it is that the author pays gas and locks the storage deposit for every byte they write.
CommentCount is the number of replies on the issue.
IterateComments walks replies oldest-first.
1type LogEntry struct {
2 Seq int64
3 Ref string
4 OldOID string // "" when the ref did not exist
5 NewOID string // "" on delete
6 Actor address
7 Height int64
8 Kind string
9 ChangeID int64 // the merged change, 0 otherwise
10 Note string
11 Digest string // hex sha256 over the previous digest and this entry
12}LogEntry is one link of the repo's reference log. The log is append-only and hash-chained: Digest commits to every earlier entry, so publishing a single digest (in a release note, a package manifest, a tweet) pins the entire history of every ref up to that point.
Ref is the current state of one reference. The history of how it got here is in the repo log, which nothing can rewrite.
1type Repo struct {
2 ID string // "<namespace>/<name>", immutable
3 Description string
4 DefaultRef string // fully-qualified, e.g. "refs/heads/main"
5 Mirrors []string
6 ParentID string // fork lineage, "" for a root repo
7 CreatedAt int64 // block height
8 Archived bool
9
10 // Merge policy.
11 RequiredApprovals int // approvals needed to merge a change
12 AllowSelfApproval bool // may the change author's own approval count
13
14 members *avl.Tree // address string -> Role
15 refs *avl.Tree // ref name -> *Ref
16 log *avl.Tree // padded seq -> *LogEntry (append-only)
17 issues *avl.Tree // padded id -> *Issue
18 changes *avl.Tree // padded id -> *Change
19
20 head string // digest of the last log entry ("" when the log is empty)
21 nextSeq int64
22 nextIssue int64
23 nextChange int64
24}Repo is one repository. Nothing here is the code: Mirrors says where the objects can be fetched, Refs says what the objects are supposed to be.
Can reports whether a holds at least the given role.
Change returns a change by id, or nil.
CloseChange withdraws or rejects a change. Author or maintainer.
1func (r *Repo) CommentChange(actor address, height, id int64, body string) (*Comment, error)CommentChange appends a reply to a change request.
CommentIssue appends a reply. Closed issues still take comments (closing is a triage state, not a gag); an archived repo takes none.
CountApprovals counts approvals that still apply: cast by a writer or above, against the change's current head, and (unless the repo allows it) not the author's own.
CountBlocking counts writers who requested changes on the current head.
1func (r *Repo) DeleteRef(actor address, height int64, name, expectedOID, note string) (*LogEntry, error)DeleteRef removes a ref by compare-and-swap. The ref disappears from the current state; the log keeps every object it ever pointed at.
Maintainer-only for an economic reason as much as a safety one: on gno.land the storage-deposit refund goes to whoever frees the bytes, not to whoever paid for them (`receiver := caller` in the vm keeper's deposit path, gno master 2026-09-19), so an open delete path pays for vandalism.
1func (r *Repo) ForceSetRef(actor address, height int64, name, newOID, note string) (*LogEntry, error)ForceSetRef moves a ref without an expectation. It needs RoleMaintainer and is permanently recorded as KindForce: the point is not to forbid a force-push (sometimes it is the right call) but to make one impossible to hide.
Issue returns an issue by id, or nil.
IterateChanges walks change requests newest-first.
IterateIssues walks issues newest-first.
IterateLog walks the log oldest-first.
IterateLogReverse walks the log newest-first, which is what a UI wants.
IterateMembers walks members in address order.
IterateRefs walks refs in name order.
LogEntryAt returns one entry by sequence number, or nil.
LogHead is the digest of the last entry, "" for an empty log. Pin this value anywhere off chain and the whole history becomes falsifiable.
LogSize is the number of entries ever appended.
MemberCount is the number of members with an explicit role.
1func (r *Repo) MergeChange(actor address, height, id int64, expectedTargetOID, mergedOID, note string) (*LogEntry, error)MergeChange moves TargetRef to mergedOID and records the move as one more entry in the reference log, tagged with the change it came from.
expectedTargetOID is a compare-and-swap on the target ref ("" when the ref does not exist yet): a change approved against one base cannot be merged onto a base that moved underneath it. mergedOID is computed off chain by whoever performs the merge: the chain records the claim, signed, ordered and attributed, and a client with the objects verifies that the result actually contains HeadOID.
1func (r *Repo) OpenChange(actor address, height int64, title, body, sourceRepo, sourceRef, headOID, targetRef string) (*Change, error)OpenChange files a change request. Permissionless, like an issue: the proposal costs its author gas and deposit, and costs a maintainer nothing until they choose to look.
OpenChangeCount counts change requests still open.
1func (r *Repo) OpenIssue(actor address, height int64, title, body string, labels []string) (*Issue, error)OpenIssue files an issue. Permissionless by design.
OpenIssueCount counts issues still open.
Ref returns the current state of a ref, or nil.
Counts for rendering.
ReviewChange records a verdict against the change's current head. Anyone may review; only a writer's approval counts toward the merge policy (see CountApprovals): an unprivileged review is signal, not authority.
RoleOf returns the member's role, RoleNone when not a member.
SetArchived freezes (or unfreezes) the repo. An archived repo takes no mutation except unarchiving: the log stays readable forever.
SetDefaultRef points the repo at another default branch.
SetDescription updates the one-line description.
SetIssueLabels replaces an issue's labels. Triage is a maintainer action.
SetIssueOpen closes or reopens an issue. The author can always close their own; maintainers can close anyone's.
SetMember grants or revokes a role. Admins manage members; only an owner may mint another owner, and the last owner cannot be demoted: a repo with no owner is a repo nobody can ever unarchive.
SetMirrors replaces the fetch locators. The first one is the canonical remote; the rest are fallbacks. The chain records them, it never fetches.
1func (r *Repo) SetPolicy(actor address, requiredApprovals int, allowSelfApproval bool) errorSetPolicy sets the merge policy: how many approvals a change needs, and whether the author's own approval counts.
1func (r *Repo) SetRef(actor address, height int64, name, expectedOID, newOID, note string) (*LogEntry, error)SetRef moves a ref by compare-and-swap: expectedOID must be the tip the caller last saw ("" to create a ref that does not exist yet). This is git's --force-with-lease, except the lease is held by consensus rather than by the server you are pushing to, so a concurrent push cannot be silently lost and a rewritten history cannot be presented as if it had always been that way.
The chain cannot check that newOID descends from expectedOID: it has no objects. That check belongs to a client holding the repo, which is exactly why every move is recorded rather than merely applied.
UpdateChangeHead repoints an open change at a new object. The author may always update their own; a writer may update anyone's (the "maintainer pushed a fixup" case).
VerifyLog recomputes the whole digest chain and reports the first entry whose digest does not follow from its predecessor. It should be impossible on a live chain: it is here because a transparency log nobody can verify is just a log, and a client should be running this against the values it read back.
Review is one reviewer's verdict on one object id.
Role is a repo-scoped capability level. Roles are totally ordered: every check is "at least this role", so there is one comparison to audit.