changeset

package
v0.4.2 Latest Latest
Warning

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

Go to latest
Published: Sep 3, 2026 License: GPL-3.0 Imports: 13 Imported by: 0

Documentation

Overview

Package changeset holds a change under review: the patch parsed into files, hunks and rows, and the shared session every surface reads while somebody works through it.

Named for the whole rather than either half. It was `diff`, which described the parsing and not the session, collided with the handler package serving /api/v1/diff and the terminal viewer, and had four callers aliasing it `session` to say what the name did not.

One object rather than three, because the daemon already multiplexes those transports over one workspace and three privately-rebuilt reviews would be three diverging opinions of the same changeset. Sharing it is what makes pairing work at all: an agent that can see where the human is looking can be useful about it, instead of narrating into the void.

State is split by lifetime, deliberately:

  • COORDINATION (cursor, suggestions) lives in memory for the daemon's life. It is about a conversation happening right now; outliving the conversation would resurrect stale suggestions into a review nobody is having.
  • DRAFTS (comments a person wrote, not yet published) are persisted. They used to sit with coordination, and that was wrong about what they are: a self-review remark is not chatter about a live conversation, it is a sentence addressed to a teammate that has not been sent yet. Losing eight of them to a daemon restart is losing the work, not forgetting a detail. An AGENT's comment stays ephemeral - it belongs to the pairing session, and reviving it into a review nobody is having is the failure the rule above names.
  • PROGRESS (which hunks the human has read) is persisted, because it is the one piece whose whole value is surviving an interruption. It is keyed by CONTENT DIGEST, so the mark survives a rebase that did not touch the hunk - which is the failing of every viewed-checkbox that resets on force-push.

Index

Constants

View Source
const (
	KindContext = "context"
	KindAdd     = "add"
	KindDel     = "del"
	KindMeta    = "meta"
)

Kind is what one row of a hunk is. Context appears on both sides; Add and Del on one. KindMeta is the "\ No newline at end of file" marker, which belongs to the hunk but is a line of neither file and must never be counted as one.

View Source
const (
	StatusAdded    = "added"
	StatusDeleted  = "deleted"
	StatusModified = "modified"
	StatusRenamed  = "renamed"
	StatusCopied   = "copied"
)

Status is what happened to a file as a whole: taken from the git extended headers where they are present, and otherwise inferred from the /dev/null convention in the ---/+++ pair.

Variables

This section is empty.

Functions

func CaptureAnchor

func CaptureAnchor(hunks []Hunk, line int) types.CommentAnchor

CaptureAnchor records what a remark on line of path's hunks should remember about the code it was written against.

Taken from the patch the reader was actually shown rather than from the file on disk. Those are the same thing for a working-tree review and different for every other kind, and a remark must attest to what its author saw.

A line the changeset does not contain yields a zero anchor rather than a guess. That is the AnchorUnknown rung, and it is honest: a remark on a file heading, or on a line no hunk covers, has no text under it to remember.

func DeclarationOf

func DeclarationOf(header string) string

DeclarationOf is the enclosing declaration git named in a hunk header line: everything after the second @@. Empty where git named none, which is ordinary - the top of a file, a language with no funcname pattern, or a hunk that spans a declaration boundary.

Exported because it is what a SURFACE renders in place of the raw header. The @@ coordinates are wire syntax: the console already prints line numbers in its gutters, so they are redundant there, and they are unreadable everywhere. What a reader wants from a hunk heading is where they are and what they are inside of.

func HunkCounts

func HunkCounts(patch string) map[string]int

HunkCounts reports how many hunks each path has, which is all a validator needs.

func HunkDigest

func HunkDigest(path string, lines []string) string

HunkDigest is the content address of one hunk: its file path and its body.

The PATH is included, so the same three lines changed in two files are two marks. The hunk HEADER is not, because its line numbers move whenever anything above it changes - a digest over them would reset every mark in a file on any edit near the top, which is the exact behavior this exists to avoid.

func LocateAnchor

func LocateAnchor(a types.CommentAnchor, hunks []Hunk, remembered int) (int, types.CommentAnchorRung)

LocateAnchor re-finds a remark's line in the hunks a reader is being shown now, and says how well it managed.

The degradation is the whole design: the quote found where it was remembered, else found elsewhere and SAID to have moved, else lost. Each rung down is a weaker claim, and the caller renders which one it got instead of presenting all three as the same answer.

remembered is the line the remark was written on. It stops being the answer the moment the file moves, and becomes the tie-breaker among equal matches - which is what makes a search for a non-unique line land where the reader meant rather than on the file's first closing brace.

func PatchDigest

func PatchDigest(patch string) string

PatchDigest is the identity of a whole patch, used to tell "the tree moved" from "the tree is the same and we simply looked again".

A session holds a changeset computed at some past moment. Without this, a client that joins later cannot tell a current answer from a frozen one, and the party least able to notice - an agent, which cannot see the tree - is the one served the stale copy.

func PlaceThreads

func PlaceThreads(files []FileHunks, threads []types.ReviewThread) []types.ReviewThread

PlaceThreads resolves each thread's line onto the hunk of its file that contains it, setting Hunk to that index or to -1 when nothing in this changeset does.

The arithmetic lives HERE, once, for the reason the parser and the emphasis do: a thread is anchored to a line of the REVIEW, and the review is not the changeset in front of the reader. Two surfaces working that out independently is the same remark landing against different code depending on where you opened it.

The NEW side, always. A host anchors an inline comment to the line as it stands after the change, which is the side a reader is looking at; matching the old side would land a remark about new code on whatever used to be there.

Threads are returned in the order given, so a caller that renders them keeps the conversation's order.

func SanitizeBidi

func SanitizeBidi(line string) (string, bool)

SanitizeBidi rewrites the characters a renderer OBEYS but a reader cannot see, and reports whether it changed anything.

The attack it exists for is Trojan Source: a bidirectional override reorders how a line is drawn without touching the bytes a compiler reads, so the reviewer and the toolchain can be made to disagree about what the line says. The same trick works with invisible characters, which hide a difference rather than reordering one.

Escaped rather than stripped, and this is the whole design. Stripping would make the line render honestly and silently misreport the file's contents - a reviewer would approve bytes magus never showed them, which is the failure this is meant to prevent rather than a milder version of it. Escaping says exactly where the character is and what it was.

It is applied to DISPLAY and never to the bytes anything else keys on: a hunk digest, a read receipt and a remark's quote anchor all address the file as it really is, so a changed rendition must not become a changed identity.

Types

type File

type File struct {
	// Path is what the file is called NOW - the new path for a rename, the old path for a
	// deletion, where there is no new one. A sidebar lists it and an anchor names it, so it
	// is never "/dev/null".
	Path string `json:"path"`
	// OldPath differs from Path only for a rename or a copy.
	OldPath   string `json:"old_path"`
	Status    string `json:"status"`
	Additions int    `json:"additions"`
	Deletions int    `json:"deletions"`
	// Binary files carry no hunks. Rendering one as an empty diff reads as "nothing changed",
	// which is false, so the flag is explicit and the surface says so.
	Binary  bool   `json:"binary"`
	OldMode string `json:"old_mode,omitempty"`
	NewMode string `json:"new_mode,omitempty"`
	Hunks   []Hunk `json:"hunks"`
}

File is one changed file, fully described.

func Parse

func Parse(patch string) []File

Parse reads a whole unified patch. An empty or whitespace-only patch yields nil rather than an error: a clean tree is a state, not a parse failure.

type FileHunks

type FileHunks struct {
	Path  string `json:"path"`
	Hunks []Hunk `json:"hunks"`
}

FileHunks is one changed file's hunks, in patch order.

func ParseHunks

func ParseHunks(patch string) []FileHunks

ParseHunks is the identity view of a patch: paths and hunk digests, without the rendering detail. It is what the session store and the MCP surface consume.

A projection of Parse, never its own pass. This is what decides which files a changeset contains, so a reader of its own here would be the one place a dialect gap turns into an empty changeset.

type Hunk

type Hunk struct {
	// Index is the 0-based position within the file, which is the coordinate the MCP surface
	// takes on a comment. It is carried explicitly rather than left implicit in the slice
	// position so a caller that filters hunks cannot silently renumber them.
	Index  int    `json:"index"`
	Header string `json:"header"`
	// Declaration is the enclosing declaration git named in Header: the text after the second
	// @@, which is "func (r Diff) AttachChurn(...)" or "type Diff struct {". Empty where git
	// named none - the top of a file, or a language it has no funcname pattern for.
	//
	// Parsed HERE rather than by each surface, for the reason the digest and the intra-line
	// emphasis are: two readers of one header is two chances to disagree about what a hunk is
	// called, and nothing would ever report the disagreement.
	Declaration string `json:"declaration,omitempty"`
	// Display is Lines with the characters a renderer obeys but a reader cannot see escaped, or
	// NIL when no line carried one - which is every ordinary hunk, so this costs nothing to ship
	// and nothing to hold.
	//
	// Computed here so the two surfaces cannot disagree about it. A sanitizer in the terminal and
	// not the browser would leave one of them rendering the deception, and the reader has no way
	// to tell which they are looking at.
	Display []string `json:"display,omitempty"`
	// Lines is the body EXACTLY as it arrived, markers included, because that is what Digest
	// hashes. Rows carries the same body parsed for rendering; the two are not interchangeable,
	// since a context line whose producer dropped the trailing space arrives as "" and would
	// come back from Rows as " ".
	Lines  []string `json:"lines"`
	Digest string   `json:"digest"`

	OldStart int   `json:"old_start"`
	OldCount int   `json:"old_count"`
	NewStart int   `json:"new_start"`
	NewCount int   `json:"new_count"`
	Rows     []Row `json:"rows"`
}

Hunk is one @@ section: the header line, its body, and the content digest the viewed set is keyed by.

type Row

type Row struct {
	Kind string `json:"kind"`
	// Text is the content WITHOUT the leading +/-/space marker.
	Text    string `json:"text"`
	OldLine *int   `json:"old_line"`
	NewLine *int   `json:"new_line"`
	// Emph is which PART of this line changed, for a row paired with its counterpart across a
	// rewrite. Nil where there is nothing to mark, which is most rows.
	//
	// Offsets are UTF-16 code units, indexing Text - what a JavaScript string is indexed by,
	// because the browser is this field's consumer. A Go caller slicing bytes converts with
	// byteSpan.
	Emph *Span `json:"emph,omitempty"`
}

Row is one rendered line of a hunk.

OldLine and NewLine are absolute numbers in the two files, nil where the line does not exist on that side. They are computed HERE rather than derived at render time because a virtualized view draws rows out of order and from arbitrary offsets, so a renderer counting as it painted would number them wrong the moment it skipped a row.

type Span

type Span struct {
	Start int `json:"start"`
	End   int `json:"end"`
}

Span is a half-open [Start, End) range of a line to emphasize. The zero Span means there is nothing to emphasize on that side; emphasize never returns a span that marks nothing, so the zero value is unambiguous.

The UNIT depends on where the span came from, which is why both spellings are named at their source: emphasize returns BYTES, because that is what a Go caller slices a string with, and Row.Emph carries UTF-16 code units, because that is what the browser it is shipped to indexes by. byteSpan converts the second back into the first.

func RawLineEmphasis

func RawLineEmphasis(h Hunk) []Span

RawLineEmphasis re-expresses a hunk's intra-line spans in the coordinates a terminal renderer slices: byte offsets into the RAW line, +/- marker included. Indexed by Rows, which the parser's push keeps parallel with Lines; an empty span means nothing to mark.

Two conversions, and each is a coordinate the other consumer does not want. Rows carry UTF-16 offsets because the wire's reader is a browser, and they measure Text, which has no marker on it. Both happen here so neither surface has to hold an opinion about how a span travelled to it.

func (Span) Empty

func (s Span) Empty() bool

Empty reports whether the span marks nothing.

type Store

type Store struct {
	// contains filtered or unexported fields
}

Store holds the live sessions and the persisted viewed set.

One session per workspace root rather than an unbounded map keyed by an opaque id: a review is about a working tree, and two sessions over one tree would let a console tab and an agent mark progress on different objects while both believed they were paired. Attaching twice returns the same session, which is what every client actually wants.

func NewStore

func NewStore(stateDir string) *Store

NewStore returns a session store persisting viewed state under stateDir. An empty stateDir keeps everything in memory, which is what a test wants and what a workspace-less daemon gets.

func (*Store) AddComment

func (s *Store) AddComment(root string, c types.DiffComment, author types.DiffAuthor) *types.DiffSession

AddComment attaches a remark. author is stamped by the CALLER from the transport the write arrived on - never from the request body - which is what stops an agent posting as the human. See types.DiffAuthor.

func (*Store) AnchorFor

func (s *Store) AnchorFor(root, path string, line int) types.CommentAnchor

AnchorFor captures what a remark at path:line should remember about the code under it, from the patch this session last tracked.

Zero when the session has never tracked that file, which is the same honest answer CaptureAnchor gives for a line no hunk covers: there is nothing under the remark to remember, and inventing something would put a quote on the record that nobody was shown.

func (*Store) AnswerSuggestion

func (s *Store) AnswerSuggestion(root, id string, accept bool) *types.DiffSession

AnswerSuggestion records the human's decision AND, on acceptance, moves the cursor - which is the only path by which a suggestion ever reaches the viewport. The human pressed a key; the agent did not move anything.

Declining is recorded rather than discarded so an agent can tell "not yet seen" from "seen and declined" and stop repeating itself.

func (*Store) Attach

func (s *Store) Attach(root string, base string, rev types.Diff, asOf string) *types.DiffSession

Attach returns the session for root, creating it and adopting any persisted viewed set on first use. review is the freshly computed annotated changeset; an existing session takes it as an update, so a client that recomputes does not clobber the conversation.

func (*Store) ContentAt

func (s *Store) ContentAt(root, path string) string

ContentAt is the fingerprint file had when this session's changeset was tracked, empty when the file was not tracked or could not be read. A caller mints a receipt from THIS rather than from the file's current bytes, so the receipt attests to what the reader saw.

The advertised scenario for this surface is a paired review where an agent edits while the human reads, so a file moving mid-session is the expected case rather than a corner.

func (*Store) DiscardDraft

func (s *Store) DiscardDraft(root, id string) *types.DiffSession

DiscardDraft removes a remark that has not been sent, so a reader can back out of one the way they can back out of a staged setting.

UNPUBLISHED and HUMAN only. A published remark exists somewhere a colleague may already have replied to, and deleting the local copy would not unsay it - it would only hide it from the person who wrote it. An agent's remark is not the reader's to delete.

func (*Store) Get

func (s *Store) Get(root string) *types.DiffSession

Get returns the session for root, or nil when none is attached.

func (*Store) LoadDrafts

func (s *Store) LoadDrafts() []types.DiffComment

LoadDrafts reads the persisted unsent remarks WITHOUT a session, for the same reason LoadSeenThreads exists: a job in its own process has no session to read.

func (*Store) LoadSeenThreads

func (s *Store) LoadSeenThreads() []string

LoadSeenThreads reads the persisted seen-thread watermark WITHOUT a session.

Exported because the check-review job runs in its own process: it has no attached session to read, and the watermark is the only thing it needs. Reading it here rather than reconstructing a session is also what keeps the job from looking like a reader who opened a review.

func (*Store) MarkPublished

func (s *Store) MarkPublished(root, id string) *types.DiffSession

MarkPublished records that a draft has left the machine.

One comment at a time even though publishing is a batch, because the caller decides which ones count as sent - see the handler's publish, which keeps a draft no provider could anchor out of the batch entirely rather than marking it here.

func (*Store) MarkThreadsSeen

func (s *Store) MarkThreadsSeen(root string, ids []string) *types.DiffSession

MarkThreadsSeen records that the reader has had these threads on screen, which is the watermark deciding what counts as NEW the next time somebody asks.

Additive and idempotent: a thread never becomes unseen, so re-rendering the same conversation costs nothing and cannot resurrect a remark as new. Ids the session already holds are skipped rather than appended twice, because this runs on every render of the surface.

func (*Store) MarkViewed

func (s *Store) MarkViewed(root, digest string, viewed bool) (sess *types.DiffSession, finished string)

MarkViewed adds or removes a hunk digest from the human's progress set and persists it.

finished names the file this mark just completed, empty when it completed none. That is what a caller mints a read receipt from, and the reason it is reported HERE rather than computed by the caller: a mark arriving on this route is live by construction - a person pressed something - which is the property a receipt rests on. The persisted viewed set is an unauthenticated file, so a file that merely LOOKS complete after a reload must never mint one on its own.

func (*Store) ResolveComment

func (s *Store) ResolveComment(root, id string, resolved bool) *types.DiffSession

ResolveComment marks a comment resolved. Either party may resolve: a human closing an agent's point and an agent closing its own after fixing it are both normal, and requiring the author to do it would strand comments whose author has gone away.

func (*Store) SetCursor

func (s *Store) SetCursor(root string, c types.DiffCursor) *types.DiffSession

SetCursor records where the HUMAN is looking. There is no agent equivalent on purpose: an agent that could write this would be moving the reader's viewport, which is the one thing the suggestion queue exists to prevent.

func (*Store) Suggest

func (s *Store) Suggest(root string, sug types.DiffSuggestion) *types.DiffSession

Suggest enqueues an agent's request for attention. It does NOT move the cursor, and that omission is the design - see types.DiffSuggestion.

func (*Store) TrackHunks

func (s *Store) TrackHunks(root string, files []FileHunks, digestAt func(path string) string)

TrackHunks records which file each hunk digest belongs to, so a later MarkViewed can say whether the mark finished a file.

Called at attach, where the patch has already been read for the snapshot id. Replaces the previous mapping wholesale: the changeset it describes has just been recomputed, and a digest from the old one no longer names anything a reader can mark. digestAt fingerprints each file's content as it is now, supplied by the caller because the hashing convention lives outside this package.

Jump to

Keyboard shortcuts

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