gigit

package module
v0.0.8 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Jul 18, 2026 License: MIT Imports: 13 Imported by: 0

README

gigit

A lightweight, embeddable versioned content distribution system in Go.

Single publisher. Multiple clients. Supports pull, rollback, diff, remote and local.

The primary use case: replacing git a s tool for distributing configuration.

Git like semantics, but no No branches, no merges; history is strictly linear and every revision is a complete snapshot of the tracked directory.

It is a small Go library first and a CLI (gg) second.

$ gg init
$ vi conf/app/settings.json
$ gg status
on revision 0
    M conf/app/settings.json  (fa17803)
$ gg commit -m "first config"
committed revision 1
$ vi conf/app/settings.json
$ gg commit -m "tighten rate limits"
committed revision 2
$ gg rollback 1
committed revision 3 (directory of revision 1)

Non-goals: branches/merges, rename tracking, multiple writers, content deltas in the logical model. If you need those, you need git.

How it works

Two self-contained subsystems and a thin top level, sharing exactly one piece of vocabulary: (path, frev) — a tracked path and its per-file version counter.

  • The index (gigit/index) knows what the directory is, per revision: delta-only changesets in chunked multi-document YAML files (100 revisions per file — a million revisions is ten thousand files, not a million) plus periodic snapshots. It never sees file contents.
  • The blob store (gigit/blobs) knows bytes and shas per (path, frev): one flat append-only file per tracked path, zstd-compressed frames. It never sees revisions of the directory.
  • The top level (gigit/repo) binds them over one folder and carries the verbs: commit, status, log, diff, checkout, update, pull, prune.

Every change in the system is a changeset (index) and a patch (blobs). The pair is the complete representation of a revision, locally and on the wire — blob storage itself is just patches at rest, so syncing is verbatim byte plumbing: a frame is compressed once at save and never re-encoded anywhere in the pipeline.

A revision log record is plain YAML:

rev: 43
time: 2026-06-04T12:00:00Z
comment: tighten rate limits
changes:
  - { path: conf/app/settings.json, op: modify, frev: 7, sha: a1b2c3d }
  - { path: conf/old.yaml, op: delete }

The full design — formats, invariants, crash-safety rules, and the alternatives that were considered and rejected — is in docs/design.md.

The rules that matter

  • The tracked universe is closed. Everything under the workdir except .gg/ is tracked; there are no ignore rules. That is what makes "commit everything detected" safe.
  • Checkout means pristine. Its postcondition is workdir == Snapshot(rev), exactly. Untracked files are dirt, not survivors.
  • No operation silently loses uncommitted work. Discarding always requires force; gg checkout -f <current> is reset.
  • Sync advances max, checkout advances current. update is fetch-only and never touches the workdir — deployed config must not change underfoot. pull is the explicit advance-live-state verb.
  • Rollback is a composition, not a verb: committing Diff(rev) makes new history that references old content — zero new blob data.

Library

import (
    "github.com/gur-shatz/gigit"
    "github.com/gur-shatz/gigit/repo"
)

r, err := repo.Init("/etc/myapp")        // or repo.Open
rev, err := r.Commit("initial", nil)     // nil = commit everything Status detects

changes, err := r.Status()               // workdir vs the current revision
css, err := r.Log(gigit.LogFilter{})     // history, newest first

// Rollback: new history, zero new blob data.
diff, err := r.Diff(42)
rev, err = r.Commit("rollback to 42", diff)

err = r.Checkout(42, false)              // visit; force=true discards dirt
err = r.Update(src)                      // fetch only; never touches the workdir
err = r.Pull(src, false)                 // update + checkout max
err = r.Prune(40)                        // drop history below 40; repo becomes shallow

src is any gigit.Remote — four read-only methods. The HTTP client (gigit/remote) is one; a *repo.Repo itself is another, so repos compose into chains (gateway ← hub ← origin) and dir-to-dir sync is just repoA.Update(repoB):

origin, _ := repo.Open("/srv/config-origin")
gateway, _ := repo.Open("/etc/myapp")
err := gateway.Pull(origin, false)       // no server, no wire

Sync over HTTP

The wire API is the Remote interface, method for method — four endpoints, all read-only (POST /patch mutates nothing; it computes):

Endpoint Role
GET /max poll for the newest revision (ETag, 304)
GET /changesets?from&to ordered changeset range; 410 = compacted, go bootstrap
GET /snapshot/{rev} the full directory as changes (bootstrap); rev or max
POST /patch changes in, binary patch stream out; 409 = divergent index

Serve a repository:

$ gg serve -addr :7421

or embed it:

http.ListenAndServe(":7421", server.Handler(r))

and pull from it:

$ gg pull http://config-hub:7421

Updates are unified: the destination folds the incoming changesets first, then fetches one patch — versions it never saw don't cross the wire. A file modified in revs 43 and 45 transfers once, at its final frev. Destinations expelled by pruning (or starting empty) bootstrap from /snapshot and are born shallow — a first-class state, not a degraded one.

Auth is a transport concern: put TLS and a bearer check in front.

Embedding the server under a subpath

The handler's routes are absolute (GET /max, …), so mount it behind http.StripPrefix — with the stdlib mux or any router. A chi application uses server.Router (or server.Attach to register on its own router) instead: the same endpoints, chi-native, no StripPrefix needed.

// chi: gigit as one subsystem of an existing application
router.Mount("/configs", server.Router(r))

// stdlib
mux.Handle("/configs/", http.StripPrefix("/configs", server.Handler(r)))

Clients then point at the full prefix:

$ gg pull https://app.example.com/configs
A client with a custom *http.Client

remote.New takes options; WithHTTPClient is where timeouts and auth live (auth is a transport concern — a bearer RoundTripper, mTLS, or whatever your proxy expects):

type bearer struct{ token string }

func (b bearer) RoundTrip(req *http.Request) (*http.Response, error) {
    req.Header.Set("Authorization", "Bearer "+b.token)
    return http.DefaultTransport.RoundTrip(req)
}

src := remote.New("https://app.example.com/configs",
    remote.WithHTTPClient(&http.Client{
        Timeout:   10 * time.Second,
        Transport: bearer{token: os.Getenv("CONFIG_TOKEN")},
    }))
err := r.Pull(src, false)

Error handling

Failures you can react to are sentinel errors in the root package, and they compose with errors.Is:

Sentinel Meaning Typical reaction
ErrDirty the operation would silently lose uncommitted work force it deliberately, or commit first
ErrBehind commit attempted while current < max Checkout(max, …) first — "checkout visits; committing Diff adopts"
ErrPruned revision below the base of a shallow repository pick a reachable revision; Update already turns the remote case into a bootstrap
ErrDivergence two repositories disagree about shared history stop and look — someone rewrote or forked history
ErrUnknownRevision revision above max refresh your idea of max
ErrCorrupt content failed its sha or a stream is broken Verify, then Recreate from a good source
// Checkout, with reset as the deliberate second step:
if err := r.Checkout(rev, false); errors.Is(err, gigit.ErrDirty) {
    err = r.Checkout(rev, true) // discard edits and untracked files — reset semantics
}

// Commit, reacting to the linearity rule:
if _, err := r.Commit("tighten limits", nil); errors.Is(err, gigit.ErrBehind) {
    max, _ := r.Max()
    _ = r.Checkout(max, false) // visit max, then commit again
}

Note what you don't handle: Update catches ErrPruned from the remote internally and re-bootstraps, and fleet.Follow wraps the whole loop — errors become failed reports and the next beat retries.

Reset and recreate

Three escalation levels, all explicit:

current, _ := r.Current()
err := r.Checkout(current, true) // reset: pristine at current, discard everything local

err = r.Recreate(src, true)      // recreate: wipe index, blobs, and directory; rebuild from src

// or fleet-wide, from the server: mark the client on the fleet page (or
// tracker.SetDirective(name, gigit.DirectiveRecreate)) and its next
// report beat rebuilds it.

To watch a live client–server pair — an origin committing config changes (including rollbacks and prunes) and a gateway following it over HTTP, each with its own admin UI:

$ make demo-sync

See examples/syncdemo.

Fleet cooperation

Beyond anonymous syncing, a source can know its clients. The optional .gg/config.yml declares them:

client: # this repository as a destination
  name: gw-tlv-01
  origin: http://hub:7421 # default src for update / pull / follow / recreate
  poll: 5s

fleet: # this repository as a source
  grace_warn: 1m # behind target this long → warn
  grace_fail: 2m # behind target this long → fail
  down_after: 5m # not seen this long → down
  clients:
    gw-tlv-01: { target: max } # follow the head
    gw-nyc-02: { target: "41" } # pinned: run revision 41

Clients run gg follow — or the library loop:

ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt)
defer stop()
err := fleet.Follow(ctx, r, src,
    fleet.WithClientName("gw-tlv-01"),
    fleet.WithPoll(5*time.Second))

Each beat polls, pulls toward the target, and — in the same round trip — reports status (POST /report) and receives orders. The server side (fleet.Tracker, behind gg serve or embedded) collects the fleet: who is where, who is behind, who went silent. Health is evaluated at read time — catching up within grace_warn is normal, behind target past grace_warn warns and past grace_fail fails, not seen past down_after is down — and everything else follows from one attachment:

import (
    "github.com/gur-shatz/go-run/pkg/backoffice" // the operator UI host
    "github.com/gur-shatz/statekit"              // states + metrics

    "github.com/gur-shatz/gigit/admin"  // backoffice pages
    "github.com/gur-shatz/gigit/fleet"  // Tracker + Follow
    "github.com/gur-shatz/gigit/repo"   // the repository
    "github.com/gur-shatz/gigit/server" // the sync API handler
)

// The fleet is the repository's: fleet.Enable inherits its configuration
// (the fleet section of .gg/config.yml) and its persistence directory
// (.gg/fleet — the client table survives restarts), and attaches itself.
// Keep the tracker only to steer: SetLock, SetDirective.
r, _ := repo.Open("/srv/config-origin")
tracker, _ := fleet.Enable(r)

// The wire: /report is discovered from the attached fleet, beside /max.
go http.ListenAndServe(":7421", server.Handler(r))

// The operator UI: one call. The repository builds its own folder —
// pages, monitoring endpoints (/state /health /metrics around r.State(),
// with the fleet riding inside as a check), and the attached fleet's
// folder, all by discovery. (examples/syncdemo/origin is the complete
// worked example.)
bo := backoffice.New()
admin.AdminFolder(bo.Folder(), r)
go bo.ListenAndServeTCP(ctx, ":7422")

// Monitoring inside a larger application is statekit's own idiom —
// components implement the interfaces, you register them. ONE
// registration: the repository's state carries the fleet as a check,
// metrics included.
_ = myAppRegistry.Register(r.State())

_ = tracker // steer with tracker.SetLock(...) / tracker.SetDirective(...)

An updater — a repository that only follows — never attaches a fleet, so all the same calls serve plain phase-1 surfaces on it.

Metrics include gigit_max_revision, gigit_commits_total, and per-client current/target/lag gauges.

Operators steer from the fleet page: lock a client to a revision ("run this revision", until clear lock), or mark it recreate — its next beat wipes local state and rebuilds from the source (Repo.Recreate, also gg recreate). Directive delivery is idempotent — the client acts only while its base is below the directive's mark, and compliance clears it. The syncdemo wires all of it.

CLI

usage: gg [-C dir] <command> [args]

  init                      create an empty repository in the current directory
  status                    changes vs the current revision
  commit -m <msg> [path..]  commit detected changes (all, or only the given paths)
  log [-path p] [-n N]      history, newest first
  cat [-rev N] <path>       print a file's content at a revision (default current)
  diff <rev>                changes bridging the newest revision to <rev>
  checkout [-f] <rev>       make the workdir pristine at <rev> (-f discards dirt)
  rollback <rev>            commit the diff to <rev>: new history, zero new data
  update [src]              fetch new revisions from <src>; never touches the workdir
  pull [-f] [src]           update, then checkout max
  follow [-poll 5s] [src]   follow <src> forever: poll, pull, report, obey directives
  recreate [-f] [src]       recovery maneuver: wipe local state, rebuild from <src>
  prune <rev>               drop history below <rev>; the repository becomes shallow
  verify                    check index invariants and blob integrity
  serve [-addr :7421]       serve this repository's read side over HTTP (+ /report)

<src> defaults to client.origin from .gg/config.yml when omitted.

Install:

$ go install github.com/gur-shatz/gigit/cmd/gg@latest

Admin UI

gg-admin serves a read-only web backoffice for one repository: overview, history search (rev range, path, comment), the full directory at any revision, file contents with download, diffs between any two revisions, and a raw workdir browser (including .gg).

$ go install github.com/gur-shatz/gigit/cmd/gg-admin@latest
$ gg-admin -addr :7422 -auth admin:secret
gigit admin for /etc/myapp — http://localhost:7422/gigit.repo/

It is deliberately a separate entrypoint: the pages are a mountable component, so the same repository can be exposed on different servers for different purposes — the standalone binary, your application's existing backoffice, or next to anything else:

import (
    "github.com/gur-shatz/go-run/pkg/backoffice"
    "github.com/gur-shatz/gigit/admin"
)

bo := backoffice.New()
admin.Mount(bo.Folder().Folder("repo"), r)

The UI is built on go-run's chiutil route folders; gg-admin and gigit/admin are the only parts of gigit that depend on it — the library core, the sync server, and gg stay on the standard library (plus yaml and zstd).

To see it on real data without setting anything up:

$ make demo-admin

generates a demo repository (examples/admindemo) with a believable config history — adds, edits, a delete, a bad change and its rollback — and serves the admin UI over it. Try searching the log for "rollback", diffing revisions 6 and 7, or opening conf/limits.yaml at two different revisions.

Crash safety

The write order everywhere — local commit and update alike — is: blob entries → log file → max pointer → workdir changes → current pointer. Each step only references data the previous step made durable, so a crash leaves unreferenced tails, never a corrupt repository. Recovery is lazy, under the repository lock, and consists of truncating those tails. Files are fsynced before renames and directories after them; pointer files move only by atomic rename; everything else is write-once.

Development

$ make build     # bin/gg
$ make test      # ginkgo -r -p
$ make dev       # watch + rebuild + retest (uses execrun from go-run)

The test suites mirror the architecture: the index suite runs over synthetic changesets with no blob store and no tracked files; the blob suite covers storage, patches, divergence, and recovery; the repo suite encodes the design document's worked examples; the server suite round-trips the wire.

License

MIT

Documentation

Overview

Package gigit is a lightweight, embeddable, git-like revision control system. No branches, no merges; history is strictly linear and every revision is a complete snapshot of the tracked directory.

The system is two self-contained subsystems and a thin top level. The subsystems share exactly one piece of vocabulary: (path, frev) — a tracked path and its per-file version counter.

  • The indexing subsystem (gigit/index) knows what the directory is, per revision: paths, frevs, status, and a 7-char short sha per change.
  • The blob subsystem (gigit/blobs) knows bytes and shas per (path, frev). It never sees revisions of the directory.

Every change in the system is a changeset (index) and a patch (blobs). The pair is the complete representation of a revision, locally and on the wire — and blob storage itself is just patches at rest.

This root package holds the shared types and the subsystem interfaces. Implementations live in gigit/index, gigit/blobs, and gigit/remote (the HTTP client); the top-level Repo that binds them lives in gigit/repo, and gigit/server exposes any Remote over HTTP.

See docs/design.md for the full design, including the wire formats and the crash-safety rules.

Index

Constants

View Source
const (
	EntryHeaderSize = 43
	PatchHeaderSize = 8

	// PatchFormatVersion is the current patch/blob stream format.
	PatchFormatVersion = 1
)
View Source
const (
	// FrevLatest selects a path's newest version; frevs start at 1.
	FrevLatest uint32 = 0

	// PruneAll, as a minimum frev in Blobs.Prune, drops the path's file
	// entirely.
	PruneAll uint32 = ^uint32(0)
)
View Source
const ConfigFile = "config.yml"

ConfigFile is the optional configuration object inside MetaDir. An absent file is the zero Config, which is exactly phase-1 behavior. It lives inside .gg, so it is metadata by definition — never tracked, never synced; each repository's config is its own.

View Source
const MetaDir = ".gg"

MetaDir is the metadata directory name at the workdir root. The tracked universe is every file under the workdir except MetaDir — there are no ignore rules in v1: a config directory is wholly owned.

View Source
const ShortShaLen = 7

ShortShaLen is the length of the index's sha prefix. Change detection is probabilistic at 28 bits — an accepted trade for a lean index; the knob for strengthening it is a longer prefix, not a cache.

Variables

View Source
var (
	// ErrNotLinear is returned by Index.Append when a preset Rev does not
	// equal Max()+1. History is strictly linear; there is no other place
	// a new revision can go.
	ErrNotLinear = errors.New("gigit: revision is not max+1, history is linear")

	// ErrPruned is returned for revisions below the base: Snapshot, Diff,
	// and Log cannot reach below a shallow repository's base revision.
	// Its HTTP form is 410 Gone — the destination should re-bootstrap.
	ErrPruned = errors.New("gigit: revision pruned away, below the base")

	// ErrUnknownRevision is returned for revisions above max.
	// Its HTTP form is 404.
	ErrUnknownRevision = errors.New("gigit: unknown revision, above max")

	// ErrDirty is returned when an operation would silently lose
	// uncommitted work: a checkout over any dirt, or a restore targeting a
	// dirty path. Discarding always requires force.
	ErrDirty = errors.New("gigit: uncommitted changes in the way, use force to discard")

	// ErrBehind is returned by Commit when current < max: new revisions
	// append after max, so update or checkout to max first
	// ("checkout visits; committing Diff adopts").
	ErrBehind = errors.New("gigit: current is behind max, checkout max before committing")

	// ErrDivergence is returned when two repositories disagree about
	// history they should share: a patch entry whose (path, frev) exists
	// locally with a different sha, or a last-shared changeset that
	// compares unequal during update.
	ErrDivergence = errors.New("gigit: histories diverge")

	// ErrUnknownVersion is returned by CreatePatch for a (path, frev) the
	// blob store does not hold. Its HTTP form is 409 — a divergent index.
	ErrUnknownVersion = errors.New("gigit: unknown (path, frev)")

	// ErrVersion is returned by AppendPatch for a patch stream whose
	// format version is unsupported.
	ErrVersion = errors.New("gigit: unsupported patch format version")

	// ErrInvalidPath is returned for paths violating the path rules —
	// incoming patches are untrusted input and materialization must never
	// escape the workdir.
	ErrInvalidPath = errors.New("gigit: invalid path")

	// ErrCorrupt is returned when stored or received content fails its
	// sha256 check, or a stream is structurally broken.
	ErrCorrupt = errors.New("gigit: corrupt data")

	// ErrUnknownClient is returned by a strict fleet for reports from
	// undeclared clients. Its HTTP form is 403.
	ErrUnknownClient = errors.New("gigit: unknown client, fleet is strict")

	// ErrReportsUnsupported is returned by the remote client when the
	// server has no /report endpoint (a phase-1 server). Followers detect
	// it and fall back to plain polling.
	ErrReportsUnsupported = errors.New("gigit: server does not support reports")
)
View Source
var PatchMagic = [4]byte{'G', 'G', 'P', 'A'}

PatchMagic opens every patch stream and .blob file.

Functions

func HashBytes

func HashBytes(content []byte) [32]byte

HashBytes is the system's content hash: sha256 over the uncompressed content. The sha is established once, at change detection, and flows down — Save validates it, never re-mints it.

func ShortSha

func ShortSha(sha [32]byte) string

ShortSha returns the index's 7-char prefix of a full sha256.

func ValidatePath

func ValidatePath(path string) error

ValidatePath enforces the path rules. It is applied at Save and at AppendPatch/checkout alike, because incoming patches are untrusted input and materialization must never escape the workdir:

  1. Relative, "/"-separated; no leading "/", no "."/".."/empty components.
  2. No NUL or control characters; no "+" (the blob separator); no "\" (portability).
  3. Must not be, or begin with, ".gg".
  4. Encoded filename ≤ 255 bytes. (Case-fold collisions are checked by the blob store against its directory, deterministically on every filesystem.)

func WriteEntryHeader

func WriteEntryHeader(w io.Writer, hdr *EntryHeader) error

WriteEntryHeader writes the normative 43-byte big-endian layout.

func WritePatchHeader

func WritePatchHeader(w io.Writer, hdr *PatchHeader) error

WritePatchHeader writes the normative 8-byte big-endian layout.

Types

type Blobs

type Blobs interface {
	// Save appends an entry. The caller supplies the content's sha256 —
	// established upstream at change detection — and Save MAY validate it
	// (cheap insurance; retrieve-time verification is the real backstop).
	// The only thing the blob layer mints is the frev: the path's last
	// frev + 1. Compression happens here, exactly once — every later
	// movement of this version is a verbatim byte copy.
	Save(path string, content []byte, sha [32]byte) (frev uint32, err error)

	// Retrieve hop-scans to the entry, decompresses, verifies the full sha.
	// The returned Version resolves FrevLatest and lets callers cross-check
	// the index's short sha.
	Retrieve(path string, frev uint32) ([]byte, Version, error)

	// Query hop-scans one path's file (FrevLatest → newest). Rarely needed:
	// the index's short shas answer most callers.
	Query(path string, frev uint32) (Version, error)

	// CreatePatch streams the entries for the given modify changes,
	// copied verbatim from the .blob files — no recompression.
	CreatePatch(changes []Change) (io.ReadCloser, error)

	// AppendPatch validates the PatchHeader (magic, supported version →
	// ErrVersion), then routes entries to their .blob files: frev > the
	// path's last → append verbatim; frev ≤ last → skip, verifying sha
	// equality where that frev exists (ErrDivergence otherwise).
	// Idempotent and resumable; entries arrive in increasing frev order
	// per path.
	AppendPatch(patch io.Reader) error

	// Prune drops history: per path, delete entries below the given
	// minimum frev (rewrite via temp + rename); a path mapped to
	// PruneAll loses its file entirely. Paths absent from the map are
	// untouched.
	Prune(min map[string]uint32) error
}

Blobs is the blob subsystem: versioned content storage per (path, frev). It never sees revisions of the directory. The entry is the single unit of both storage and exchange — a .blob file and a patch are the same format, and every movement of a saved frame is a verbatim byte copy.

type Change

type Change struct {
	Path string `yaml:"path" json:"path"`
	Op   Op     `yaml:"op" json:"op"`
	Frev uint32 `yaml:"frev,omitempty" json:"frev,omitempty"` // version identity; modify only
	Sha  string `yaml:"sha,omitempty" json:"sha,omitempty"`   // 7-char sha256 prefix; modify only
}

Change is one line of a changeset: the target state of one path.

Sha is a comparison heuristic and cross-check, deliberately not an identity — Frev is the identity, and short-sha equality across different frevs must never be acted on. The authoritative full sha256 lives in the blob entry header, of which this is a prefix.

func Unify

func Unify(css []Changeset) []Change

Unify folds changesets (in rev order) into the bridging changes: per path the last op wins, so a file touched many times appears once, with its newest frev. Pure — needs no storage. The input is sorted by rev defensively; the output is sorted by path.

func (Change) MarshalYAML

func (this Change) MarshalYAML() (any, error)

MarshalYAML renders a change as a single flow-style mapping, so a changeset reads one change per line:

  • { path: conf/app/settings.json, op: modify, frev: 7, sha: a1b2c3d }

type Changeset

type Changeset struct {
	Rev     uint64    `yaml:"rev" json:"rev"`
	Time    time.Time `yaml:"time,omitempty" json:"time,omitzero"`        // log changesets only
	Comment string    `yaml:"comment,omitempty" json:"comment,omitempty"` // log changesets only
	Changes []Change  `yaml:"changes" json:"changes"`
}

Changeset is the single index object: log file, snapshot, or diff result.

Changeset is reserved for historical records (log files and the /changesets wire). Everything derived — snapshots, diff results, unified updates — is a bare []Change: no rev, no time, no comment.

func (Changeset) Equal

func (this Changeset) Equal(other Changeset) bool

Equal compares two changesets structurally. Used for divergence detection: log files are immutable, so refetching the last shared changeset and comparing is the integrity check.

type ClientConfig

type ClientConfig struct {
	Name   string   `yaml:"name,omitempty"`   // identity in reports; default: hostname
	Origin string   `yaml:"origin,omitempty"` // default src for update / pull / follow / recreate
	Poll   Duration `yaml:"poll,omitempty"`   // follow-mode cadence; default 5s
}

ClientConfig describes this repository as a destination.

type ClientState

type ClientState string

ClientState is the client's own verdict on itself, carried in reports.

const (
	StateOk     ClientState = "ok"     // operating, tracking
	StateStable ClientState = "stable" // at target, clean, for ≥3 consecutive beats
	StateFailed ClientState = "failed" // last maneuver failed; Detail says why
)

type Config

type Config struct {
	Index  IndexConfig  `yaml:"index,omitempty"`
	Client ClientConfig `yaml:"client,omitempty"`
	Fleet  FleetConfig  `yaml:"fleet,omitempty"`
	UI     UIConfig     `yaml:"ui,omitempty"`
}

Config is the .gg/config.yml object. Precedence everywhere: explicit code options override the file, CLI flags override both.

func LoadConfig

func LoadConfig(dir string) (Config, error)

LoadConfig reads <dir>/.gg/config.yml. A missing file is the zero Config, not an error.

type Directive

type Directive string

Directive is a server-issued recovery maneuver, delivered with orders. Delivery is repeated and idempotent: the client acts on DirectiveRecreate only while its base < Orders.RecreateTo, so a directive raced with its own fulfillment cannot loop.

const (
	DirectiveNone     Directive = ""
	DirectiveRecreate Directive = "recreate"
)

type Duration

type Duration time.Duration

Duration is a time.Duration that reads and writes as "5s"/"2m" in YAML.

func (Duration) MarshalYAML

func (this Duration) MarshalYAML() (any, error)

func (Duration) Std

func (this Duration) Std() time.Duration

Std returns the standard library form.

func (*Duration) UnmarshalYAML

func (this *Duration) UnmarshalYAML(node *yaml.Node) error

type EntryHeader

type EntryHeader struct {
	Frev    uint32    // explicit — NOT ordinal; gaps are legal and normal
	Type    FrameType // frame encoding
	PathLen uint16    // path bytes following the header
	Len     uint32    // frame bytes following the path
	Sha     [32]byte  // sha256 of the uncompressed content (authoritative)
}

EntryHeader opens every entry, followed by PathLen bytes of path and Len bytes of frame. The on-disk/wire format is *defined* as:

err := binary.Write(buf, binary.BigEndian, &hdr) // exactly 43 bytes

This works because every field is fixed-size (declared order, no padding); adding a variable-size field would silently break it — don't.

func ReadEntryHeader

func ReadEntryHeader(r io.Reader) (EntryHeader, error)

ReadEntryHeader reads the normative 43-byte big-endian layout. io.EOF means a clean end of stream; a partial header is ErrCorrupt.

type FleetClient

type FleetClient struct {
	// Target is "max" (default, follow the head) or a decimal revision
	// number (pinned: "run this revision").
	Target string `yaml:"target,omitempty"`
}

FleetClient declares one expected client.

func (FleetClient) ResolveTarget

func (this FleetClient) ResolveTarget(max uint64) (uint64, error)

ResolveTarget resolves the declared target against the source's max.

type FleetConfig

type FleetConfig struct {
	GraceWarn Duration               `yaml:"grace_warn,omitempty"` // behind target this long → warn; default 1m
	GraceFail Duration               `yaml:"grace_fail,omitempty"` // behind target this long → fail; default 2m
	DownAfter Duration               `yaml:"down_after,omitempty"` // no report this long → down; default 5m
	Strict    bool                   `yaml:"strict,omitempty"`     // reports from undeclared clients are rejected
	Clients   map[string]FleetClient `yaml:"clients,omitempty"`    // the declared fleet — "know your clients"
}

FleetConfig describes this repository as a source with a fleet.

The health clocks: catching up within GraceWarn is normal (pass); behind target past GraceWarn is warn, past GraceFail is fail; a client not seen for DownAfter is down — silence is its own verdict, above failure.

type FrameType

type FrameType uint8

FrameType is the encoding of one entry's content frame.

const (
	FrameZstd FrameType = 0 // whole-version zstd; delta frames reserved for v2
)

type Index

type Index interface {
	// Append is the only writer. It assigns cs.Rev = Max()+1 and returns it
	// (a preset Rev must equal Max()+1: ErrNotLinear otherwise), writes
	// log/<rev>.yml, then advances max_revision.txt.
	Append(cs Changeset) (rev uint64, err error)

	// Snapshot folds the nearest stored snapshot ≤ rev forward to the
	// full directory at rev (all modify, one change per live path); one is
	// persisted every K revisions.
	Snapshot(rev uint64) ([]Change, error)

	// Diff returns the changes bridging src → target: modify with target
	// frev where differing or new, delete where gone. (Snapshot of both,
	// compared.)
	Diff(src, target uint64) ([]Change, error)

	// Log returns changesets newest-first, filtered by rev range and/or path.
	Log(f LogFilter) ([]Changeset, error)

	Current() (uint64, error)
	SetCurrent(rev uint64) error // atomic rename of current.txt
	Max() (uint64, error)        // reads max_revision.txt

	// Base is the earliest reachable revision: 0 unless shallow.
	// Snapshot, Diff, and Log below it fail with ErrPruned.
	Base() (uint64, error)

	// InstallBase makes the index shallow at rev in one step: store the
	// snapshot as the new base, ensure max ≥ rev, no log records below.
	// Used by bootstrap and Prune.
	InstallBase(rev uint64, snapshot []Change) error

	// Verify checks log numbering is dense above base and snapshots
	// replay correctly.
	Verify() error
}

Index is the indexing subsystem: what the directory is, per revision. It knows paths, frevs, status, and a 7-char short sha per change — never sizes, storage offsets, or actual bytes. It can run standalone against synthetic changesets with no real files at all.

This set is closed — anything more belongs to the top level.

type IndexConfig added in v0.0.2

type IndexConfig struct {
	// ChunkSize is how many revisions share one log chunk file
	// (default 100) — the inode knob.
	ChunkSize uint64 `yaml:"chunk_size,omitempty"`

	// SnapshotEvery is the snapshot cadence in chunks (default 1): one
	// snapshot per this many chunks, always on a chunk boundary, so a
	// fold reads at most this many chunk files.
	SnapshotEvery uint64 `yaml:"snapshot_every,omitempty"`
}

IndexConfig tunes the indexing subsystem's storage.

type LogFilter

type LogFilter struct {
	From uint64
	To   uint64
	Path string
}

LogFilter selects changesets for Index.Log. Zero values mean unbounded: From 0 = the earliest reachable revision, To 0 = max, Path "" = all paths.

type Op

type Op string

Op is the target state of one path in a change.

const (
	OpModify Op = "modify" // target state: present as (Frev, Sha) — covers add
	OpDelete Op = "delete" // target state: absent — the tombstone
)

type Orders

type Orders struct {
	Target     uint64    `json:"target"`
	Directive  Directive `json:"directive,omitempty"`
	RecreateTo uint64    `json:"recreate_to,omitempty"` // set with DirectiveRecreate
}

Orders is the server's response to a report: the client's resolved target and any directive.

type PatchHeader

type PatchHeader struct {
	Magic   [4]byte // "GGPA"
	Version uint16  // format version; currently 1
	Flags   uint16  // reserved, must be 0
}

PatchHeader opens every entry stream — wire patches and .blob files alike. Same encoding: binary.Write(buf, binary.BigEndian, &hdr). The global header makes every stream self-identifying: format evolution is detected up front, not by failing mid-stream.

func NewPatchHeader

func NewPatchHeader() PatchHeader

NewPatchHeader returns the header for the current format version.

func ReadPatchHeader

func ReadPatchHeader(r io.Reader) (PatchHeader, error)

ReadPatchHeader reads and validates a stream's opening header: a wrong magic is ErrCorrupt, an unsupported version is ErrVersion.

type Remote

type Remote interface {
	Max() (uint64, error)
	Changesets(from, to uint64) ([]Changeset, error)
	Snapshot(rev uint64) ([]Change, error)         // bootstrap
	Patch(changes []Change) (io.ReadCloser, error) // remote create_patch
}

Remote is the read-only face of any update source. The HTTP API is its wire form; a shared filesystem, an S3 bucket, or a test fake satisfy the same four methods. So does *repo.Repo itself — its read side is a Remote — which lets repos compose into chains (gateway ← hub ← origin) and makes dir-to-dir sync just repoA.Update(repoB).

type Report

type Report struct {
	Client  string            `json:"client" yaml:"client"`
	Current uint64            `json:"current" yaml:"current"`
	Max     uint64            `json:"max" yaml:"max"`
	Base    uint64            `json:"base" yaml:"base"` // where it starts from (shallowness)
	State   ClientState       `json:"state" yaml:"state"`
	Detail  string            `json:"detail,omitempty" yaml:"detail,omitempty"` // the error when failed
	Info    map[string]string `json:"info,omitempty" yaml:"info,omitempty"`     // free-form: version, uptime, …
}

Report is one client status beat — "who I am, where I am, where I started from, how I feel".

type ReportHandler

type ReportHandler interface {
	HandleReport(r Report) (Orders, error)
}

ReportHandler is what the server's /report endpoint calls. Defined here so gigit/server never imports fleet; fleet.Tracker implements it.

type Reporter

type Reporter interface {
	Report(r Report) (Orders, error)
}

Reporter is the optional client-side capability: report status, receive orders, in one round trip. remote.Client implements it; *repo.Repo and other read-only Remotes deliberately do not — dir-to-dir chains don't report.

type UIConfig

type UIConfig struct {
	LogLimit int `yaml:"log_limit,omitempty"` // history page size; default 50
}

UIConfig holds admin UI preferences.

type Version

type Version struct {
	Frev uint32
	Sha  [32]byte
}

Version identifies one stored version of one path — the (path, frev) vocabulary's blob-side half, carrying the authoritative full sha.

Directories

Path Synopsis
Package admin is the gigit backoffice UI: a read-only administrative interface for browsing a repository — overview, history search, revision directories, file contents at any revision, diffs, and the raw workdir (including .gg) — built on go-run's chiutil route folders.
Package admin is the gigit backoffice UI: a read-only administrative interface for browsing a repository — overview, history search, revision directories, file contents at any revision, diffs, and the raw workdir (including .gg) — built on go-run's chiutil route folders.
Package blobs implements the blob subsystem: versioned content storage per (path, frev).
Package blobs implements the blob subsystem: versioned content storage per (path, frev).
cmd
gg command
Command gg is the gigit CLI: a minimal revision control system for wholly-owned config/state directories.
Command gg is the gigit CLI: a minimal revision control system for wholly-owned config/state directories.
gg-admin command
Command gg-admin serves the gigit backoffice UI for one repository: a read-only web interface for browsing its state, history, directories, file contents, and diffs.
Command gg-admin serves the gigit backoffice UI for one repository: a read-only web interface for browsing its state, history, directories, file contents, and diffs.
examples
admindemo command
Command admindemo builds a demo repository with a believable config history — adds, modifications, a delete, a bad change and its rollback — then serves the admin backoffice over it.
Command admindemo builds a demo repository with a believable config history — adds, modifications, a delete, a bad change and its rollback — then serves the admin backoffice over it.
syncdemo/gateway command
Command gateway is the client half of the sync demo: a deployed destination running fleet.Follow — poll, pull toward the target, report every beat, obey orders (lock it to a revision or mark it for recreate on the origin's fleet page).
Command gateway is the client half of the sync demo: a deployed destination running fleet.Follow — poll, pull toward the target, report every beat, obey orders (lock it to a revision or mark it for recreate on the origin's fleet page).
syncdemo/origin command
Command origin is the server half of the sync demo: a repository that keeps living.
Command origin is the server half of the sync demo: a repository that keeps living.
Package fleet is the client–server cooperation layer: the Tracker is the server's collected view of its clients (reports in, orders out, health and metrics evaluated over the facts), and Follow is the client loop that feeds it.
Package fleet is the client–server cooperation layer: the Tracker is the server's collected view of its clients (reports in, orders out, health and metrics evaluated over the facts), and Follow is the client loop that feeds it.
Package index implements the indexing subsystem over one directory: what the directory is, per revision.
Package index implements the indexing subsystem over one directory: what the directory is, per revision.
internal
atomicfile
Package atomicfile is the one place the §7 durability discipline is implemented: rename gives ordering, not persistence — fsync the file before its rename, and fsync the containing directory after.
Package atomicfile is the one place the §7 durability discipline is implemented: rename gives ordering, not persistence — fsync the file before its rename, and fsync the containing directory after.
webui
Package webui is the shared page chrome for gigit's backoffice pages: one layout, one palette (chiutil's folder index's, dark by default, light under prefers-color-scheme), used by gigit/admin's repository pages and gigit/fleet's admin folder alike.
Package webui is the shared page chrome for gigit's backoffice pages: one layout, one palette (chiutil's folder index's, dark by default, light under prefers-color-scheme), used by gigit/admin's repository pages and gigit/fleet's admin folder alike.
Package remote is the HTTP client side of the sync API: a gigit.Remote over the four endpoints, the exact mirror of gigit/server.
Package remote is the HTTP client side of the sync API: a gigit.Remote over the four endpoints, the exact mirror of gigit/server.
Package repo is the top level: a Repo binds the indexing and blob subsystems over one folder and carries the top-level verbs — commit, status, log, diff, checkout, update, pull, prune.
Package repo is the top level: a Repo binds the indexing and blob subsystems over one folder and carries the top-level verbs — commit, status, log, diff, checkout, update, pull, prune.
Package server exposes any gigit.Remote over HTTP: the four endpoints are exactly the Remote interface, method for method — the transport is an implementation detail, and the usual server is a *repo.Repo with its Remote face exposed.
Package server exposes any gigit.Remote over HTTP: the four endpoints are exactly the Remote interface, method for method — the transport is an implementation detail, and the usual server is a *repo.Repo with its Remote face exposed.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL