On this page
Package trailer reads and writes the key-value metadata lines at the end of a commit message: the session attribution safegit injects, and the move records a commit declares.
#internal/trailer
#internal/trailer
Package trailer reads and writes the key-value metadata lines at the end of a commit message: the session attribution safegit injects, and the move records a commit declares.
Three things live here, in three layers:
- the trailer BLOCK: finding it in a message (SplitBodyTrailers), appending to it (Inject, AppendCustom) and reading it as key-value pairs (Trailers); - the move-record FORMAT: one encoder and one decoder for the old -> new pair grammar, shared by the record writer, the --moved validator and safegit mv (moved.go, cquote.go, ulid.go); - the PROJECTION that reads records back against the trees the repository holds (project.go), where the trees, not the records, have the last word.
#MovedKey
const MovedKey = "Moved"MovedKey is the trailer key one move record is written under.
#MovedRetractKey
const MovedRetractKey = "Moved-Retract"MovedRetractKey is the trailer key a retraction is written under. Its value is the id of the record being retracted and nothing else.
Retraction is the ONLY correction: a record already written is never edited, because a record only exists on a commit and editing that commit rewrites history. A replacement is a retraction plus a new record in one commit.
#OriginDeclared
const OriginDeclared Origin = ""OriginDeclared is a claim a person made. It is the zero value and it is written as NO token at all, which is what makes every record ever written a declared one without anything being migrated.
#OriginObserved
const OriginObserved Origin = "observed"OriginObserved is a claim safegit derived from a commit's delta.
#NoOverlap
const NoOverlap OverlapKind = iotaNoOverlap: the two moves are about different paths entirely.
#SameSource
const SameSourceSameSource: their source paths nest, so they state two fates for one file.
#SameDestination
const SameDestinationSameDestination: their destination paths nest, so they describe a result no move produces.
#Chained
const ChainedChained: one path is both a destination and a source, so the outcome would depend on which move was performed first.
#SessionKey
const SessionKey = "Claude-Code-Session-Id"SessionKey is the git trailer key used to record the Claude Code session ID.
#Origin
type Origin stringOrigin says how a record's claim was established.
It is written as the token immediately after the id, in the slot the grammar reserves for it (cquote.go), and ABSENCE IS A VALUE: a record with no token is a DECLARED one -- a person stating a move, which is what every record written before the token existed is and what every --moved, safegit mv and revert-inverse record still is.
observed is the one token written today. It means safegit DERIVED the claim from what a commit's own delta witnesses -- objects it read, not intent anybody stated -- so a reader who wants to know whether a person vouched for the move has the answer without asking anyone. The remaining reserved words (declared, derived) stay refused in that slot until something gives them a meaning.
#Record
type Record structRecord is one move record.
Old and New are canonical repo-relative paths. A trailing slash on both marks the SUBTREE form, which claims a move of everything under the prefix rather than of one file: the per-file answers are derived when the record is read and validated against the trees then, so a record written for a directory stays one line however many files the directory holds and however many of them a later reader finds.
Origin says who established the claim; see Origin. It changes nothing about what the record CLAIMS -- the trees remain the arbiter of every record, whatever its origin (project.go).
#Moves
type Moves structMoves is everything one commit message declares about moves.
Malformed carries the values under the two keys that do not parse, verbatim. They are neither dropped in silence nor turned into a hard error: a reader asking about a path must not be stopped by an unrelated commit somebody's tool mangled, and a tool auditing the repository must be able to find it.
#KV
type KV structKV is one trailer line: its key and everything after the "Key: ".
A continuation line -- an indented line following a trailer -- is appended to the preceding value with its newline and its indentation preserved, which is how git reads one too. safegit's own structured trailers never produce one (a path holding a newline is escaped, not wrapped), so a continuation in practice comes from somebody else's tool and is carried rather than interpreted.
#Tree
type Tree interfaceTree is the arbiter: the set of paths one commit holds.
#PathSet
type PathSet map[string]struct{}PathSet is a Tree over an explicit set of paths.
#Commit
type Commit structCommit is one commit as a projection reads it: what it declared, and the trees that decide whether the declarations hold.
Parents is every parent's tree, in parent order, and it is empty for a root commit -- which is why a root commit's records can never apply: nothing preceded it for anything to move from. Tree is the commit's own.
#Hop
type Hop structHop is one record the projection applied.
#Projection
type Projection structProjection is the answer for one followed path.
#Pair
type Pair structPair is one declared move's two paths, in either the file form or the subtree form. The trailing slash is trimmed wherever these paths are compared, so a caller may hand over whichever form it holds.
#OverlapKind
type OverlapKind intOverlapKind names how two declared moves speak about each other's paths.
#RecordTransformError
type RecordTransformError structRecordTransformError reports a transform that would turn a readable move record into one the decoder refuses.
Re-encoding through the one encoder keeps the QUOTING readable whatever the substitution did, but the pair itself still has to be a move: two different paths, both or neither naming a subtree, neither of them empty. A replacement can map both sides onto one path, eat a subtree marker on one side only, or empty a token -- and the line that would be written is then a claim nobody can read, sitting inert in history where the rewrite meant to correct it.
So the transform refuses rather than writing it, and the caller turns the refusal into a rewrite that never starts. The record is never edited into half a claim and never dropped in silence either: a record is a whole statement, and the only ways out are a replacement that keeps it one, erasing the path outright, or retracting the record in a commit of its own.
#EncodePair
func EncodePair(old, new string) stringEncodePair renders one "old -> new" token pair: the grammar --moved takes, the grammar safegit mv takes, and the tail of every written record. One encoder, so the three cannot drift apart.
#ParsePair
func ParsePair(s string) (old, new string, err error)ParsePair reads one "old -> new" token pair.
The separator is found OUTSIDE quoted regions, so a path that holds the arrow's own shape parses correctly once it is quoted -- and a value carrying two unquoted separators is refused rather than split at a guess.
An unquoted token is taken verbatim after its surrounding spaces are trimmed, which is what lets a person type --moved 'src/a.go -> src/b.go' (and even a non-ASCII path) without quoting anything. A token that really does carry a space, a quote, a backslash or a control byte has to be quoted, because nothing else could tell the two sides apart. One consequence worth stating: an unquoted token containing a lone double quote opens a quoted region that never closes, and the refusal says so rather than guessing.
#ValidatePair
func ValidatePair(old, new string) errorValidatePair applies the grammar's own rules to a decoded pair: the rules that hold wherever the pair came from, as opposed to the repository-dependent ones (is the old path tracked, is the new one there) that only a caller holding a tree can answer.
#EncodeRecord
func EncodeRecord(r Record) stringEncodeRecord renders the VALUE of one Moved: trailer -- the id, the origin token where there is one, then the pair.
A declared record writes no token, which is exactly the shape records had before the token existed: nothing in a repository has to be rewritten, and a reader that has never heard of origins reads every one of them the way it always did.
#RecordLine
func RecordLine(r Record) stringRecordLine renders a whole Moved: trailer line, without its newline.
#RetractLine
func RetractLine(id string) stringRetractLine renders a whole Moved-Retract: trailer line, without its newline.
#ParseRecord
func ParseRecord(value string) (Record, error)ParseRecord reads the value of one Moved: trailer.
#NewRecord
func NewRecord(old, new string, origin Origin) (Record, error)NewRecord mints an id and returns the record for one pair. The pair is validated first, so a record never exists for a pair the grammar refuses.
The origin is a PARAMETER rather than a default: every mint site knows whether it is writing down what a person said or what safegit read off a delta, and a default would let a site that never thought about it write the wrong answer in silence.
#ReadMoves
func ReadMoves(message string) MovesReadMoves parses one commit message's move declarations.
#MovedLines
func MovedLines(message string) []stringMovedLines returns the message's Moved: and Moved-Retract: trailer lines exactly as they are written, continuation lines included.
It is what an amend or a reword re-appends when a new -m replaces the message: dropping a record is a RETRACTION, never the side effect of rewording the commit that carries it, so the lines are carried across verbatim rather than re-encoded from a parse (which would silently normalize -- or lose -- a line this version does not understand).
#Trailers
func Trailers(message string) []KVTrailers parses the trailer block of a commit message. A message with no trailer block yields nothing.
#ParseTrailerBlock
func ParseTrailerBlock(block string) []KVParseTrailerBlock parses an already-located trailer block.
#NewPathSet
func NewPathSet(paths ...string) PathSetNewPathSet builds a PathSet from a list of paths.
#Forward
func Forward(path string, chain []Commit) ProjectionForward follows path through a chain of commits given oldest first, and answers what it is called at the end.
The chain is the caller's: this package walks no history and resolves no revision. A caller with a linear range hands over that range; a caller following a first-parent line hands over that line, with each commit's OTHER parents still listed in Parents so a record made on the side that was merged in is recognized as applying to something that existed.
#RetractedIDs
func RetractedIDs(chain []Commit) map[string]boolRetractedIDs collects every record id the chain retracts.
Folding is over the WHOLE chain rather than forward from each retraction: an id names exactly one record, so where in the chain the retraction sits cannot change which record it names. A retraction naming an id the chain does not carry is inert -- the record it retracts may simply be outside the range the caller handed over.
#Nests
func Nests(a, b string) boolNests reports whether two paths are the same path or one is inside the other. It is the one answer to "do these two declarations speak about each other's paths", and Overlap is what both callers of that question -- the --moved refusal and safegit mv -- ask it through.
#Overlap
func Overlap(a, b Pair) (kind OverlapKind, x, y string)Overlap reports whether two declared moves can stand as one statement, and it is the ONE implementation of that question: safegit mv asks it of the pairs it is about to rename, and --moved asks it of the records a commit or an amend is about to write. The two spellings are the same declaration, so a command line either of them refuses is refused by both.
Three ways two moves collide:
- NESTING ON THE SOURCE SIDE: src/ -> lib/ alongside src/one.txt -> x says two different things about one file. A reader could resolve that by longest match; a writer guessing which the caller meant would be the silent precedence rule this tool does not have. - NESTING ON THE DESTINATION SIDE: two moves landing inside one another describe a result no move produces. - CHAINING: one path that is both a destination and a source, as in a -> b beside b -> c. The result would depend on the order the moves happened to be performed in, which is not something a caller stated.
The two returned paths are the ones that nest, for a refusal to name.
#RemoveMovedRecordsNaming
func RemoveMovedRecordsNaming(message, path string) (string, bool)RemoveMovedRecordsNaming drops every Moved: record whose pair names path, returning the new message and whether anything was dropped.
It is what a rewrite that ERASES a path from history does to the records that reference it: the path is being removed from every tree, so a record still pointing at it is one more reference to the thing being erased. The record is removed rather than edited, because a record is a whole claim -- half of a move is not a smaller move, it is a malformed one.
A retraction naming a removed record's id is left where it is. It names an id nothing carries any more, which the projection already treats as inert, and deleting it would be a second edit to a message for no gain.
#RewriteMessage
func RewriteMessage(message string, transform func(string) string) (string, error)RewriteMessage applies a text transform to a commit message without breaking the quoting grammar of a move record.
The body is transformed verbatim, which is what a pattern substitution has always done to a whole message. Inside a Moved: line only the DECODED path tokens are transformed, and the result is re-encoded through the one encoder, so the record that comes out parses however aggressively the transform rewrote the paths. The record's ID is never transformed: it is a generated name, not content, and a rewritten id names nothing.
Every other trailer -- including a Moved: line that does not parse, which is somebody else's malformed line and not ours to normalize -- is transformed verbatim, exactly as before.
One consequence worth stating: a pattern that matches only the ESCAPED spelling of a path (\101 rather than A) matches nothing here, because the transform never sees the escaped form. The rewrite's own verification is what notices that the pattern survived, and it refuses the rewrite -- which is the honest outcome, and the alternative was a corrupt record.
A transform whose result is no longer a MOVE is refused: the returned message is empty and the error is a *RecordTransformError, which the caller turns into a rewrite that never starts. See that type for why the record is neither written broken nor dropped in silence.
#Inject
func Inject(message string) stringInject reads CLAUDE_CODE_SESSION_ID from the environment and appends a Claude-Code-Session-Id trailer to the commit message if present. For amend: deduplicates if the same session ID already exists as a trailer; keeps both if a different session's trailer is present.
#AppendCustom
func AppendCustom(message string, trailers []string) stringAppendCustom appends user-provided trailers to the commit message. Each trailer should be in "Key: Value" format. If trailers is empty, the message is returned unchanged. Follows the same format as Inject: appends to an existing trailer block, or adds a blank line separator first.
#SplitBodyTrailers
func SplitBodyTrailers(message string) (body, trailerBlock string)SplitBodyTrailers splits a commit message into the body (everything before the trailer block) and the trailer block (trailing Key: Value lines preceded by a blank line). Continuation lines (indented lines following a trailer) are included in the trailer block.
If the message has no trailers, body is the entire message and trailerBlock is empty. If the entire message consists of trailer- format lines with no blank-line separator, body is empty and trailerBlock is the entire message.
#ReplaceIdentity
func ReplaceIdentity(message, oldName, newName, oldEmail, newEmail string) stringReplaceIdentity replaces author identity in identity-bearing trailers (lines whose key ends in "-by", such as Signed-off-by, Co-authored-by, Reviewed-by, Acked-by). Within those trailer lines, it replaces "oldName
#NewID
func NewID() (string, error)NewID mints a fresh record id. It fails only when the system's entropy source does, which is not a condition to paper over: an id drawn from a degraded source could collide with another record's, and a retraction naming it would then retract the wrong record.
#ValidID
func ValidID(s string) boolValidID reports whether s is a well-formed record id.
It is strict about case: the encoder emits upper case, and accepting lower case would make two spellings of one id, which is exactly what a retraction must not have to guess about.
#Origin.Name
func (o Origin) Name() stringName is the origin's word, for a reader or a payload that needs one where the encoding writes nothing. An absent token is still an answer, and its answer is "declared".
#Record.Subtree
func (r Record) Subtree() bool { return strings.HasSuffix(r.Old, "/") }Subtree reports whether this record claims a whole prefix rather than one path.
#Record.OldPrefix
func (r Record) OldPrefix() string { return strings.TrimSuffix(r.Old, "/") }OldPrefix is Old without the subtree form's trailing slash.
#Record.NewPrefix
func (r Record) NewPrefix() string { return strings.TrimSuffix(r.New, "/") }NewPrefix is New without the subtree form's trailing slash.
#PathSet.Has
func (s PathSet) Has(path string) boolHas reports whether the set holds this exact path.
#PathSet.HasUnder
func (s PathSet) HasUnder(prefix string) boolHasUnder reports whether the set holds any path under this prefix.
#Record.Names
func (r Record) Names(path string) boolNames reports whether this record's pair names path.
For the file form that is the two paths themselves and nothing else -- a file-form record says nothing about a descendant, which is the same rule the projection applies. For the subtree form it is anything at or under either prefix, on both sides: a record claiming src/ -> lib/ references lib/deep/one.txt as surely as it references lib itself.
#RecordTransformError.Error
func (e *RecordTransformError) Error() string#RecordTransformError.Unwrap
func (e *RecordTransformError) Unwrap() error { return e.Err }