Artifact 96945905835a65a8a1f287236b12e020256ccf84d1b8020c56892a2f99e74b45:
- File
internal/deck/marshal.go
— part of check-in
[29c2c5c6f5]
at
2026-07-24 12:19:28
on branch trunk
— checkin: emit renames as a single F-card with the w placeholder (#147)
* fix: emit renames as a single F-card with the "w" placeholder (#51)
buildCommitFiles seeded its working set from the parent manifest and layered vfile changes on top, but never consulted vfile.origname. A pure rename has identical content, so ScanChanges clears its chnged flag; the new name then never entered the working set while the old name carried forward from the parent, so the manifest emitted the file's content under its old pathname and dropped the rename entirely.
Drive renames off vfile.origname, mirroring canonical Fossil's manifest query (src/checkin.c:1939): applyRenames retires the old pathname and records it on the surviving new-name entry, so a rename is emitted exactly once. The unchanged-content pass then loads the blob and preserves OldName.
Serialize the rename card to match stock Fossil: force a "w" permission placeholder when a renamed file's perm would otherwise be empty, keeping the prior-name field in its 4th positional slot (src/checkin.c:1999). Thread OldName through manifest.File and storeFileBlobs so the commit path can carry it.
Verified the exact card "F greet.txt <uuid> w hello.txt" against a real fossil binary for the same rename scenario.
* fix: never emit a renamed-then-deleted file in the commit manifest
applyRenames enumerated every vfile row with origname != pathname, including rows marked deleted, and unconditionally seeded a new-name entry for them. A file that was renamed and then unmanaged therefore committed as a rename F-card under its new name. The deleted-files pass could not catch it: it only knows the file's current pathname, so deleting the new name from a map keyed on parent-manifest pathnames was a no-op and nothing retired the old name.
Canonical Fossil excludes deleted rows from its manifest query outright (WHERE NOT deleted OR NOT is_selected). Match that: a deleted renamed row now retires both names and emits nothing, keeping the existing guard that leaves the old name alone when a live vfile row has reclaimed it.
Also move the deck marshaller's prior-name field inside the UUID guard. The field is positional and only meaningful after a hash; a deletion card carrying one would serialize as "F name oldname", with the old name misread as the UUID.
Tests: rename-then-delete emits no card under either name; a rename chain that recycles a freed pathname emits both cards with the right prior names and content. (user: [email protected], size: 5714) [annotate] [blame] [check-ins using]
package deck import ( "fmt" "sort" "strings" ) func (d *Deck) Marshal() ([]byte, error) { if d == nil { panic("deck.Marshal: d must not be nil") } // §4.7.16: a tag name that is entirely hexadecimal after the sign is // ambiguous with an artifact hash, so the parser rejects it. Refusing to // emit one keeps the codec symmetric -- without this check a caller can // build and store an artifact this repository can no longer read back. // Canonical fossil never produces one either: it prefixes branch and // user tags with "sym-". for _, tag := range d.T { if isHexToken(tag.Name) { return nil, fmt.Errorf("T-card name %q must not be entirely hexadecimal", tag.Name) } } var b strings.Builder marshalCards(&b, d) body := b.String() zHash := computeZ([]byte(body)) return []byte(fmt.Sprintf("%sZ %s\n", body, zHash)), nil } // marshalCards writes all cards in ASCII order to the builder. func marshalCards(b *strings.Builder, d *Deck) { if b == nil { panic("deck.marshalCards: b must not be nil") } if d == nil { panic("deck.marshalCards: d must not be nil") } // Cards in strict ASCII order: A B C D E F G H I J K L M N P Q R T U W Z if d.A != nil { b.WriteString("A ") b.WriteString(FossilEncode(d.A.Filename)) b.WriteString(" ") b.WriteString(d.A.Target) if d.A.Source != "" { b.WriteString(" ") b.WriteString(d.A.Source) } b.WriteString("\n") } if d.B != "" { fmt.Fprintf(b, "B %s\n", d.B) } if d.C != "" { fmt.Fprintf(b, "C %s\n", FossilEncode(d.C)) } if !d.D.IsZero() { fmt.Fprintf(b, "D %s\n", d.D.UTC().Format("2006-01-02T15:04:05.000")) } if d.E != nil { fmt.Fprintf(b, "E %s %s\n", d.E.Date.UTC().Format("2006-01-02T15:04:05"), d.E.UUID) } if len(d.F) > 0 { sorted := make([]FileCard, len(d.F)) copy(sorted, d.F) sort.Slice(sorted, func(i, j int) bool { return Compare(sorted[i].Name, sorted[j].Name) < 0 }) for _, f := range sorted { b.WriteString("F ") b.WriteString(FossilEncode(f.Name)) if f.UUID != "" { b.WriteString(" ") b.WriteString(f.UUID) // Canonical Fossil forces a "w" permission placeholder on a // rename card whose perm would otherwise be empty, so the // prior-name field keeps its 4th positional slot rather than // being misread as the permission (src/checkin.c:1999). perm := f.Perm if perm == "" && f.OldName != "" { perm = "w" } if perm != "" { b.WriteString(" ") b.WriteString(perm) } // The prior name is positional and only meaningful after a // UUID; a deletion card (no UUID) must never carry one, or // the old name would be misread as the hash. if f.OldName != "" { b.WriteString(" ") b.WriteString(FossilEncode(f.OldName)) } } b.WriteString("\n") } } if d.G != "" { fmt.Fprintf(b, "G %s\n", d.G) } if d.H != "" { fmt.Fprintf(b, "H %s\n", FossilEncode(d.H)) } if d.I != "" { fmt.Fprintf(b, "I %s\n", d.I) } if len(d.J) > 0 { // §4.5.2 requires a strictly ascending J run, which the parser // enforces. Callers build TicketField slices in whatever order // suits them, so wire order is the marshaller's to impose — the // same responsibility it already takes for F, M and T. sorted := make([]TicketField, len(d.J)) copy(sorted, d.J) sort.Slice(sorted, func(i, j int) bool { return Compare(sorted[i].Name, sorted[j].Name) < 0 }) for _, j := range sorted { // §4.7.8 mirror of the parse direction: the name is written // verbatim, the value is escape-encoded. if j.Value != "" { fmt.Fprintf(b, "J %s %s\n", j.Name, FossilEncode(j.Value)) } else { fmt.Fprintf(b, "J %s\n", j.Name) } } } if d.K != "" { fmt.Fprintf(b, "K %s\n", d.K) } if d.L != "" { fmt.Fprintf(b, "L %s\n", FossilEncode(d.L)) } if len(d.M) > 0 { sorted := make([]string, len(d.M)) copy(sorted, d.M) sort.Slice(sorted, func(i, j int) bool { return Compare(sorted[i], sorted[j]) < 0 }) for _, m := range sorted { fmt.Fprintf(b, "M %s\n", m) } } if d.N != "" { fmt.Fprintf(b, "N %s\n", d.N) } if len(d.P) > 0 { fmt.Fprintf(b, "P %s\n", strings.Join(d.P, " ")) } for _, q := range d.Q { prefix := "+" if q.IsBackout { prefix = "-" } if q.Baseline != "" { fmt.Fprintf(b, "Q %s%s %s\n", prefix, q.Target, q.Baseline) } else { fmt.Fprintf(b, "Q %s%s\n", prefix, q.Target) } } if d.R != "" { fmt.Fprintf(b, "R %s\n", d.R) } if len(d.T) > 0 { sorted := make([]TagCard, len(d.T)) copy(sorted, d.T) // Two-level (decoded name, target hash) per §4.5.2 and §4.7.16, // not a comparison of the concatenated key: concatenating lets the // target's leading characters compete with the tail of a longer // name, which reorders tags whose names are prefixes of one // another into a run the parser rejects. tagOrderKey is the same // key the parser's check uses, so the two cannot drift. sort.Slice(sorted, func(i, j int) bool { if cmp := Compare(tagOrderKey(sorted[i]), tagOrderKey(sorted[j])); cmp != 0 { return cmp < 0 } return Compare(sorted[i].UUID, sorted[j].UUID) < 0 }) for _, tag := range sorted { // §4.7.16: the name and value are escape-encoded (a branch name // may hold a space, and the value carries that same name on a // `T *branch` card); the target token is written verbatim, never // encoded. fmt.Fprintf(b, "T %c%s %s", tag.Type, FossilEncode(tag.Name), tag.UUID) if tag.Value != "" { fmt.Fprintf(b, " %s", FossilEncode(tag.Value)) } b.WriteString("\n") } } if d.U != nil && *d.U != "" { fmt.Fprintf(b, "U %s\n", FossilEncode(*d.U)) } if len(d.W) > 0 { fmt.Fprintf(b, "W %d\n%s\n", len(d.W), d.W) } }