package checkout
import (
"database/sql"
"errors"
"fmt"
"os"
"path"
"path/filepath"
"sort"
"github.com/danmestas/go-libfossil/db"
"github.com/danmestas/go-libfossil/internal/blob"
"github.com/danmestas/go-libfossil/internal/content"
libfossil "github.com/danmestas/go-libfossil/internal/fsltype"
"github.com/danmestas/go-libfossil/internal/merge"
"github.com/danmestas/go-libfossil/internal/vfile"
)
// MergeAction is what Merge did, or would do, to one file.
type MergeAction int
const (
// MergeUpdated: only the merged-in version changed the file, so its
// content replaced the checkout's.
MergeUpdated MergeAction = iota + 1
// MergeMerged: both sides changed the file and the strategy combined
// them cleanly.
MergeMerged
// MergeConflict: both sides changed the file and the merged content,
// conflict markers included, was written into it.
MergeConflict
// MergeKept: the file could not be merged and the checkout's copy was
// left as it is. MergeFile.Reason says why.
MergeKept
// MergeForked: the conflict-fork strategy recorded every version in the
// repository's conflict table and left the checkout's copy as it is.
MergeForked
// MergeAdded: the file is new in the merged-in version.
MergeAdded
// MergeDeleted: the merged-in version deleted the file and the checkout
// had not changed it.
MergeDeleted
// MergeMode: only the merged-in version changed the file's executable
// bit, so its bit was taken.
MergeMode
)
// MergeOpts configures Merge.
type MergeOpts struct {
Version libfossil.FslID // the checkin to merge in; required
Strategy string // strategy for every file; "" picks one per file
DryRun bool // plan the merge and report it, changing nothing
}
// MergeFile reports one file Merge acted on.
type MergeFile struct {
Name string
Action MergeAction
Strategy string // the strategy that merged the file, when one ran
Reason string // why the file was kept, for MergeKept
}
// MergeResult reports a merge. Files is sorted by name and is empty when
// there was nothing to merge.
type MergeResult struct {
Ancestor libfossil.FslID // the common ancestor the merge diffed against
Files []MergeFile
}
// Merge merges another checkin into the checkout, the way fossil's merge
// command does, leaving the result uncommitted. Changes the merged-in
// version made since the common ancestor are applied to the working files;
// a file both sides changed is merged by its strategy, with conflict markers
// written into the file when the strategy cannot reconcile them. A conflict
// is not recorded anywhere else: the file is in conflict while it holds
// markers (see Conflicts). The merged-in version becomes the next commit's
// merge parent.
//
// The checkout records the merge as fossil does, so fossil reads it too:
// a taken-over file gets chnged=2 and mrid set to the merged-in content, an
// added file chnged=3, a merged file a per-file vmerge row, and the merge
// itself a vmerge row with id 0.
//
// Merge refuses, changing nothing, when the repository is missing content
// it needs or when an added file would overwrite an untracked file. In a
// sparse checkout, uncovered changes are recorded without touching disk. Where
// fossil relies on its undo copy, Merge keeps the checkout's file instead:
// a file edited here but deleted in the merged-in version is kept and
// reported, not deleted. Renames are not followed: a file renamed on one
// side is a deletion and an addition to the other.
//
// opts.Strategy names the strategy for every file; empty picks one per file
// from the repository's .libfossil-merge rules and merge-strategy setting,
// falling back to three-way. Merging the checkout's own version or one of
// its ancestors does nothing and returns an empty result.
func (c *Checkout) Merge(opts MergeOpts) (MergeResult, error) {
if c == nil {
panic("checkout.Merge: nil *Checkout")
}
if opts.Version <= 0 {
return MergeResult{}, fmt.Errorf("checkout.Merge: invalid version rid %d", opts.Version)
}
vid, _, err := c.Version()
if err != nil {
return MergeResult{}, fmt.Errorf("checkout.Merge: %w", err)
}
if vid <= 0 {
return MergeResult{}, fmt.Errorf("checkout.Merge: nothing is checked out")
}
if opts.Version == vid {
return MergeResult{}, nil
}
pivot, err := merge.FindCommonAncestor(c.repo, vid, opts.Version)
if err != nil {
return MergeResult{}, fmt.Errorf("checkout.Merge: common ancestor: %w", err)
}
if pivot == opts.Version {
return MergeResult{Ancestor: pivot}, nil // already part of the checkout
}
// Which files are edited decides between taking the merged-in version
// and merging, so chnged must be current. As fossil's merge, refuse
// something other than a file where a tracked file belongs.
if err := c.ScanChanges(ScanENotFile); err != nil {
return MergeResult{}, fmt.Errorf("checkout.Merge: %w", err)
}
plan := mergePlan{vid: vid, pivot: pivot, mid: opts.Version}
if err := c.planMerge(&plan, opts.Strategy); err != nil {
return MergeResult{}, fmt.Errorf("checkout.Merge: %w", err)
}
if err := c.checkMergeTargets(plan.steps); err != nil {
return MergeResult{}, fmt.Errorf("checkout.Merge: %w", err)
}
if !opts.DryRun {
if err := c.applyMerge(plan); err != nil {
return MergeResult{}, fmt.Errorf("checkout.Merge: %w", err)
}
}
return plan.result(), nil
}
// mergePlan is a merge worked out in full before anything is changed.
type mergePlan struct {
vid, pivot, mid libfossil.FslID
steps []mergeStep // sorted by name
}
// mergeStep is one file's part of a merge.
type mergeStep struct {
file MergeFile
row vfile.Row // the checkout's row; zero for an added file
merged mergeSide // the merged-in version of the file
content []byte // what to write; nil when the file is not written
exe bool // the checkout file's executable bit after the merge
fork forkSides // which versions have the file, for a fork record
recordOnly bool // the shared cone decision; no filesystem access for this step
pivotRID libfossil.FslID
}
// forkSides says which of the three versions a conflict-fork record names.
type forkSides struct {
base, local, remote bool
}
// mergeSide is one version's copy of a file. rid 0 means the version does
// not have the file.
type mergeSide struct {
rid libfossil.FslID
uuid string
exe bool
link bool
}
func (p mergePlan) result() MergeResult {
files := make([]MergeFile, len(p.steps))
for i, s := range p.steps {
files[i] = s.file
}
return MergeResult{Ancestor: p.pivot, Files: files}
}
// mergeKind is how a file's three versions combine.
type mergeKind int
const (
mergeSkip mergeKind = iota // the merged-in version did not change it
mergeTake // changed only there: take its content
mergeThreeWay // changed on both sides: merge
mergeAdd // new there
mergeDelete // deleted there, untouched here
mergeKeepEdited // deleted there, edited here
mergeKeepAbsent // changed there, not tracked here
mergeKeepRemoved // changed there, removal pending here
mergeMode // only the executable bit changed there
)
// classifyMerge decides how a file combines, from the checkout's row (nil
// when the checkout does not track the file), whether the checkout changed
// it, and the ancestor's and merged-in version's copies. It follows the
// cases of fossil's merge_cmd.
func classifyMerge(row *vfile.Row, p, m mergeSide) mergeKind {
if m.rid == p.rid {
if row != nil && !row.IsRemoved() && m.rid != 0 && takesMergedExe(*row, p, m) {
return mergeMode
}
return mergeSkip
}
if row == nil {
if p.rid == 0 {
return mergeAdd
}
if m.rid == 0 {
return mergeSkip // deleted on both sides
}
return mergeKeepAbsent
}
if row.IsRemoved() {
if m.rid == 0 {
return mergeSkip
}
return mergeKeepRemoved
}
unchanged := !row.ContentChanged()
if m.rid == 0 {
if unchanged && libfossil.FslID(row.RID) == p.rid {
return mergeDelete
}
return mergeKeepEdited
}
if unchanged && libfossil.FslID(row.RID) == p.rid {
return mergeTake
}
if unchanged && libfossil.FslID(row.RID) == m.rid {
return mergeSkip // the checkout already has the merged-in content
}
return mergeThreeWay
}
// takesMergedExe reports whether the merged-in version changed the file's
// executable bit and the checkout did not, so the merge takes its bit, as
// fossil's merge does for a file whose content it leaves alone. (For a file
// it merges, fossil always takes the merged-in bit; go-libfossil keeps a
// change the checkout made to it.)
func takesMergedExe(row vfile.Row, p, m mergeSide) bool {
if m.exe == p.exe {
return false
}
return (row.IsExe > 0) == p.exe
}
// mergedExe is the executable bit a merged file ends up with.
func mergedExe(row vfile.Row, p, m mergeSide) bool {
if takesMergedExe(row, p, m) {
return m.exe
}
return row.IsExe > 0
}
// keepReasons explains each kind of file Merge keeps without strategy.
var keepReasons = map[mergeKind]string{
mergeKeepEdited: "edited here, deleted in the merged-in version",
mergeKeepAbsent: "changed in the merged-in version, not in this checkout",
mergeKeepRemoved: "changed in the merged-in version, removed here",
}
// planMerge fills plan.steps with every file the merge touches. It reads all
// the content the merge needs, so a merge it cannot finish fails here.
func (c *Checkout) planMerge(plan *mergePlan, strategyName string) error {
if plan.vid <= 0 || plan.mid <= 0 {
panic("checkout.planMerge: versions must be positive")
}
choose, err := c.mergeStrategyChooser(plan.vid, strategyName)
if err != nil {
return err
}
rows, err := c.effectiveRows(plan.vid)
if err != nil {
return err
}
byName := make(map[string]*vfile.Row, len(rows))
for i := range rows {
byName[rows[i].Pathname] = &rows[i]
}
ancestor, err := c.mergeSides(plan.pivot)
if err != nil {
return err
}
merged, err := c.mergeSides(plan.mid)
if err != nil {
return err
}
for _, name := range mergeNames(byName, ancestor, merged) {
row, p, m := byName[name], ancestor[name], merged[name]
kind := classifyMerge(row, p, m)
if kind == mergeSkip {
continue
}
step, err := c.planStep(name, kind, row, p, m, choose)
if err != nil {
return err
}
plan.steps = append(plan.steps, step)
}
return nil
}
// mergeNames is every name any of the three versions has, sorted.
func mergeNames(rows map[string]*vfile.Row, p, m map[string]mergeSide) []string {
seen := make(map[string]bool, len(rows)+len(m))
for name := range rows {
seen[name] = true
}
for name := range p {
seen[name] = true
}
for name := range m {
seen[name] = true
}
names := make([]string, 0, len(seen))
for name := range seen {
names = append(names, name)
}
sort.Strings(names)
return names
}
// mergeSides lists checkin rid's files by name. It refuses a checkin whose
// content the repository does not fully hold, as fossil's merge does.
func (c *Checkout) mergeSides(rid libfossil.FslID) (map[string]mergeSide, error) {
files, blobRIDs, err := c.resolveFiles(rid, nil)
if err != nil {
return nil, err
}
sides := make(map[string]mergeSide, len(files))
for i, f := range files {
sides[f.Name] = mergeSide{
rid: blobRIDs[i],
uuid: f.UUID,
exe: f.Perm == "x",
link: f.Perm == "l",
}
}
return sides, nil
}
// mergeStrategyChooser returns the strategy for each file: the named one for
// every file, or the repository's rules for each.
func (c *Checkout) mergeStrategyChooser(
vid libfossil.FslID, name string,
) (func(string) (merge.Strategy, error), error) {
if name != "" {
strat, ok := merge.StrategyByName(name)
if !ok {
return nil, fmt.Errorf("unknown merge strategy %q", name)
}
return func(string) (merge.Strategy, error) { return strat, nil }, nil
}
resolver := merge.LoadResolver(c.repo, vid)
return func(file string) (merge.Strategy, error) {
picked := resolver.Resolve(file)
strat, ok := merge.StrategyByName(picked)
if !ok {
return nil, fmt.Errorf("unknown merge strategy %q for %s", picked, file)
}
return strat, nil
}, nil
}
// planStep works out one file's step: what happens to it and, for a file
// that is written, its new content.
func (c *Checkout) planStep(
name string, kind mergeKind, row *vfile.Row, p, m mergeSide,
choose func(string) (merge.Strategy, error),
) (mergeStep, error) {
if kind == mergeSkip {
panic("checkout.planStep: nothing to plan for " + name)
}
step := mergeStep{file: MergeFile{Name: name}, merged: m, recordOnly: !c.covers(name), pivotRID: p.rid}
if row != nil {
step.exe = mergedExe(*row, p, m)
}
step.fork = forkSides{
base: p.rid > 0,
local: row != nil && !row.IsRemoved(),
remote: m.rid > 0,
}
if row != nil {
step.row = *row
}
if step.recordOnly {
switch kind {
case mergeTake:
step.file.Action = MergeUpdated
case mergeAdd:
step.file.Action = MergeAdded
case mergeDelete:
step.file.Action = MergeDeleted
case mergeMode:
step.file.Action = MergeMode
default:
step.file.Action, step.file.Reason = MergeConflict, "outside both-changed path requires widening the sparse cone"
}
return step, nil
}
if reason, ok := keepReasons[kind]; ok {
return step, keepOrFork(&step, reason, choose)
}
if m.link || p.link || step.row.IsLink > 0 {
step.file.Action, step.file.Reason = MergeKept, "symlinks are not merged"
return step, nil
}
var err error
switch kind {
case mergeTake, mergeAdd:
step.file.Action = MergeUpdated
if kind == mergeAdd {
step.file.Action = MergeAdded
}
step.content, err = content.Expand(c.repo.DB(), m.rid)
case mergeDelete:
step.file.Action = MergeDeleted
case mergeMode:
step.file.Action = MergeMode
case mergeThreeWay:
err = c.planThreeWay(&step, p, choose)
default:
panic(fmt.Sprintf("checkout.planStep: unhandled merge kind %d", kind))
}
if err != nil {
return mergeStep{}, fmt.Errorf("%s: %w", name, err)
}
return step, nil
}
// keepOrFork settles a file that cannot be merged: kept as it is, or, under
// the conflict-fork strategy, recorded as a fork so every version survives.
func keepOrFork(
step *mergeStep, reason string, choose func(string) (merge.Strategy, error),
) error {
if reason == "" {
panic("checkout.keepOrFork: no reason for " + step.file.Name)
}
strat, err := choose(step.file.Name)
if err != nil {
return err
}
if strat.Name() == "conflict-fork" {
step.file.Action, step.file.Strategy = MergeForked, strat.Name()
return nil
}
step.file.Action, step.file.Reason = MergeKept, reason
return nil
}
// planThreeWay merges the ancestor's copy, the working file and the merged-in
// copy with the file's strategy, and records the outcome in step.
func (c *Checkout) planThreeWay(
step *mergeStep, p mergeSide, choose func(string) (merge.Strategy, error),
) error {
if step.row.Pathname == "" {
panic("checkout.planThreeWay: no checkout row for " + step.file.Name)
}
local, present, err := c.readWorkingFile(step.row)
if err != nil {
return err
}
if !present {
return keepOrFork(step, "missing from the checkout", choose)
}
base := []byte{}
if p.rid > 0 {
if base, err = content.Expand(c.repo.DB(), p.rid); err != nil {
return err
}
}
remote, err := content.Expand(c.repo.DB(), step.merged.rid)
if err != nil {
return err
}
strat, err := choose(step.file.Name)
if err != nil {
return err
}
if !(merge.IsText(base) && merge.IsText(local) && merge.IsText(remote)) {
// Fossil's merge refuses binary content: "cannot merge binary
// file", and the checkout's copy stays, whatever strategy the file
// would have had.
step.file.Strategy = strat.Name()
step.file.Action = MergeKept
step.file.Reason = "it is a binary file"
return nil
}
res, err := strat.Merge(base, local, remote)
if err != nil {
return err
}
step.file.Strategy = strat.Name()
switch {
case res.Clean:
step.file.Action, step.content = MergeMerged, res.Content
case strat.Name() == "conflict-fork":
step.file.Action = MergeForked
case merge.HasConflictMarkers(res.Content):
step.file.Action, step.content = MergeConflict, res.Content
default:
step.file.Action = MergeKept
step.file.Reason = "the " + strat.Name() + " strategy cannot merge it"
}
return nil
}
// readWorkingFile reads row's file from disk. present is false when it is
// missing or is not a regular file.
func (c *Checkout) readWorkingFile(row vfile.Row) (data []byte, present bool, err error) {
if !c.covers(row.Pathname) {
if row.IsRemoved() || row.MRID <= 0 {
return nil, false, nil
}
data, err := content.Expand(c.repo.DB(), libfossil.FslID(row.MRID))
return data, err == nil, err
}
missing, err := c.isMissing(row)
if err != nil {
return nil, false, err
}
if missing {
return nil, false, nil
}
fullPath, err := c.safePath(row.Pathname)
if err != nil {
return nil, false, err
}
data, err = c.env.Storage.ReadFile(fullPath)
if err != nil {
return nil, false, fmt.Errorf("read %s: %w", fullPath, err)
}
if data == nil {
data = []byte{}
}
return data, true, nil
}
// checkMergeTargets refuses, before anything is written, a merge that could
// not finish or would destroy something: an added file over an untracked
// file holding other content (fossil backs it up and overwrites it;
// go-libfossil has no undo copy, so it refuses, as Extract does), or a file
// the merge writes or deletes where something other than a regular file
// stands, or under a parent that is not a directory. Failing on those
// midway would leave files merged on disk with no record of the merge.
func (c *Checkout) checkMergeTargets(steps []mergeStep) error {
for _, s := range steps {
if !s.changesDisk() {
continue
}
if err := c.checkMergeTarget(s.file.Name); err != nil {
return err
}
if s.file.Action != MergeAdded {
continue
}
clobber, err := c.wouldClobber(s.file.Name, s.merged.uuid)
if err != nil {
return err
}
if clobber {
return fmt.Errorf(
"merge would overwrite untracked file %s; move it aside first", s.file.Name,
)
}
}
return nil
}
// changesDisk reports whether applying the step writes, deletes or changes
// the mode of its file.
func (s mergeStep) changesDisk() bool {
if s.recordOnly {
return false
}
switch s.file.Action {
case MergeUpdated, MergeMerged, MergeConflict, MergeAdded, MergeDeleted, MergeMode:
return true
default:
return false
}
}
// checkMergeTarget refuses a path whose file is not a regular file, or one
// of whose parents is not a directory. Storage has no Lstat, so a symlink is
// judged by what it points to.
func (c *Checkout) checkMergeTarget(name string) error {
fullPath, err := c.safePath(name)
if err != nil {
return err
}
info, err := c.env.Storage.Stat(fullPath)
if err == nil && !info.Mode().IsRegular() {
return fmt.Errorf("cannot merge %s: it is not a regular file", name)
}
if err != nil && !errors.Is(err, os.ErrNotExist) {
return fmt.Errorf("stat %s: %w", fullPath, err)
}
// Walk the checkout-relative name, so the walk ends at the checkout root
// whatever form c.dir takes.
clean, err := c.CheckFilename(name)
if err != nil {
return err
}
for dir := path.Dir(filepath.ToSlash(clean)); dir != "."; dir = path.Dir(dir) {
info, err := c.env.Storage.Stat(filepath.Join(c.dir, filepath.FromSlash(dir)))
if errors.Is(err, os.ErrNotExist) {
continue
}
if err != nil {
return fmt.Errorf("stat %s: %w", dir, err)
}
if !info.IsDir() {
return fmt.Errorf("cannot merge %s: %s is not a directory", name, dir)
}
}
return nil
}
// applyMerge carries out a planned merge: each step's working file and
// checkout record, then the merge itself, in one checkout transaction; then
// the repository's fork records.
func (c *Checkout) applyMerge(plan mergePlan) error {
if plan.mid <= 0 {
panic("checkout.applyMerge: no merged-in version")
}
var names []string
for _, s := range plan.steps {
if s.changesDisk() {
names = append(names, s.file.Name)
}
}
saved, err := c.readFilesUndo(names)
if err != nil {
return err
}
tx, err := c.db.Begin()
if err != nil {
return fmt.Errorf("begin: %w", err)
}
if err := saved.save(tx); err != nil {
return errors.Join(err, tx.Rollback())
}
if err := c.applyMergeTx(tx, plan); err != nil {
return errors.Join(err, tx.Rollback(), c.rollBackUpdate(saved))
}
if err := tx.Commit(); err != nil {
return errors.Join(fmt.Errorf("commit: %w", err), c.rollBackUpdate(saved))
}
return c.recordForks(plan)
}
// storeMergePayload keeps new uncommitted bytes out of public sync inventory.
// Existing public content retains its visibility; undo retains private payloads.
func (c *Checkout) storeMergePayload(data []byte) (rid libfossil.FslID, uuid string, err error) {
err = c.repo.DB().WithTx(func(tx *db.Tx) error {
var outcome blob.Outcome
rid, uuid, outcome, err = blob.StoreUnderPolicy(tx, data)
if err != nil {
return err
}
if outcome != blob.Found {
return content.MakePrivate(tx, int64(rid))
}
return nil
})
return rid, uuid, err
}
func (c *Checkout) applyMergeTx(tx *sql.Tx, plan mergePlan) error {
for _, s := range plan.steps {
if err := c.applyMergeStep(tx, plan.vid, s); err != nil {
return fmt.Errorf("%s: %w", s.file.Name, err)
}
}
mhash, err := blobUUID(c.repo.DB(), plan.mid)
if err != nil {
return err
}
return insertVMerge(tx, 0, plan.mid, mhash)
}
// alwaysDiffers stands in for hashing a file known to differ from its
// baseline.
func alwaysDiffers() (bool, error) { return true, nil }
// applyMergeStep writes one file and records it the way fossil's merge
// does.
func (c *Checkout) applyMergeStep(tx *sql.Tx, vid libfossil.FslID, s mergeStep) error {
if s.recordOnly {
return c.applyRecordOnlyMerge(tx, vid, s)
}
if s.changesDisk() {
if err := c.applyMergeDiskStep(s); err != nil {
return err
}
}
switch s.file.Action {
case MergeUpdated:
_, err := tx.Exec(`UPDATE vfile SET mtime=0, chnged=2, mrid=?, mhash=?, isexe=?
WHERE id=?`, int64(s.merged.rid), s.merged.uuid, s.merged.exe, s.row.ID)
return err
case MergeAdded:
_, err := tx.Exec(`INSERT INTO vfile(vid, chnged, deleted, rid, mrid, isexe, islink,
pathname) VALUES(?, 3, 0, ?, ?, ?, 0, ?)`,
int64(vid), int64(s.merged.rid), int64(s.merged.rid), s.merged.exe, s.file.Name)
return err
case MergeDeleted:
_, err := tx.Exec("UPDATE vfile SET deleted=1 WHERE id=?", s.row.ID)
return err
case MergeMode:
_, err := tx.Exec("UPDATE vfile SET isexe=? WHERE id=?", s.exe, s.row.ID)
return err
case MergeMerged, MergeConflict:
if s.recordOnly {
rid, uuid, err := c.storeMergePayload(s.content)
if err != nil {
return err
}
if _, err := tx.Exec("UPDATE vfile SET mrid=?, mhash=? WHERE id=?", int64(rid), uuid, s.row.ID); err != nil {
return err
}
}
// What a scan would make of content that now differs from the
// baseline; a scan corrects it should the merge have changed nothing.
next, err := nextChangedState(s.row.Chnged, scanFacts{hasBaseline: true}, alwaysDiffers)
if err != nil {
return err
}
if s.recordOnly {
next = 2 // mrid owns the synthesized bytes, even after materialization
}
if _, err := tx.Exec("UPDATE vfile SET mtime=0, chnged=?, isexe=? WHERE id=?",
next, s.exe, s.row.ID); err != nil {
return err
}
return insertVMerge(tx, s.row.ID, s.merged.rid, s.merged.uuid)
case MergeKept, MergeForked:
// Fossil records a file its merge loop reached even when the merge
// kept the checkout's copy (a binary file); a file no strategy saw
// was not reached.
if s.file.Strategy == "" || s.row.ID <= 0 || s.merged.rid <= 0 {
return nil
}
return insertVMerge(tx, s.row.ID, s.merged.rid, s.merged.uuid)
default:
panic(fmt.Sprintf("checkout.applyMergeStep: unknown action %d", s.file.Action))
}
}
// applyMergeDiskStep is the filesystem half of a materialized merge step.
func (c *Checkout) applyMergeDiskStep(s mergeStep) error {
switch s.file.Action {
case MergeUpdated, MergeAdded:
return c.writeMergedFile(s.file.Name, s.content, s.merged.exe)
case MergeMerged, MergeConflict:
return c.writeMergedFile(s.file.Name, s.content, s.exe)
case MergeDeleted:
full, err := c.safePath(s.file.Name)
if err != nil {
return err
}
return c.removeRegularFile(full)
case MergeMode:
return c.setMergedMode(s.file.Name, s.exe)
default:
panic("checkout.applyMergeDiskStep: step does not change disk")
}
}
// writeMergedFile writes a merge's content for name with the executable
// bit exe.
func (c *Checkout) writeMergedFile(name string, data []byte, exe bool) error {
if data == nil {
panic("checkout.writeMergedFile: no content for " + name)
}
fullPath, err := c.safePath(name)
if err != nil {
return err
}
if err := c.env.Storage.MkdirAll(filepath.Dir(fullPath), 0o755); err != nil {
return fmt.Errorf("mkdir: %w", err)
}
perm := os.FileMode(0o644)
if exe {
perm = 0o755
}
if err := c.env.Storage.WriteFile(fullPath, data, perm); err != nil {
return fmt.Errorf("write %s: %w", fullPath, err)
}
// WriteFile keeps an existing file's mode.
return c.setMergedMode(name, exe)
}
// setMergedMode gives name's file the executable bit exe, as fossil's
// file_setexe does (vfile.Disk.SetExe).
func (c *Checkout) setMergedMode(name string, exe bool) error {
fullPath, err := c.safePath(name)
if err != nil {
return err
}
disk, err := c.newDiskView()
if err != nil {
return err
}
return disk.SetExe(fullPath, exe)
}
// insertVMerge records that merged-in artifact rid reached the checkout: id
// 0 for the merge as a whole (the next commit's merge parent), or the vfile
// id of a file merged from it. Fossil's checkout schema makes (id, mhash)
// unique; go-libfossil's never did, so the uniqueness is checked here.
func insertVMerge(tx *sql.Tx, id int64, rid libfossil.FslID, mhash string) error {
if id < 0 {
panic("checkout.insertVMerge: negative id")
}
if rid <= 0 {
panic("checkout.insertVMerge: rid must be positive")
}
_, err := tx.Exec(`INSERT INTO vmerge(id, merge, mhash)
SELECT ?, ?, ? WHERE NOT EXISTS (SELECT 1 FROM vmerge WHERE id=? AND mhash=?)`,
id, int64(rid), mhash, id, mhash)
if err != nil {
return fmt.Errorf("record merge: %w", err)
}
return nil
}
// blobUUID returns the hash of artifact rid.
func blobUUID(q interface {
QueryRow(string, ...any) *sql.Row
}, rid libfossil.FslID) (string, error) {
var uuid string
if err := q.QueryRow("SELECT uuid FROM blob WHERE rid=?", int64(rid)).Scan(&uuid); err != nil {
return "", fmt.Errorf("hash of rid %d: %w", rid, err)
}
return uuid, nil
}
// recordForks writes a conflict-table entry for each file the conflict-fork
// strategy kept, naming the ancestor, checkout and merged-in checkins.
func (c *Checkout) recordForks(plan mergePlan) error {
for _, s := range plan.steps {
if s.file.Action != MergeForked {
continue
}
if err := merge.EnsureConflictTable(c.repo); err != nil {
return fmt.Errorf("conflict table: %w", err)
}
var base, local, remote int64
if s.fork.base {
base = int64(plan.pivot)
}
if s.fork.local {
local = int64(plan.vid)
}
if s.fork.remote {
remote = int64(plan.mid)
}
err := merge.RecordConflictFork(c.repo, s.file.Name, base, local, remote)
if err != nil {
return fmt.Errorf("record fork for %s: %w", s.file.Name, err)
}
}
return nil
}
// Conflicts lists the files, sorted, that are in conflict: changed files
// that still hold merge conflict markers, as fossil's changes command
// decides it. Resolving a conflict is editing the markers out of the file.
func (c *Checkout) Conflicts() ([]string, error) {
if c == nil {
panic("checkout.Conflicts: nil *Checkout")
}
vid, _, err := c.Version()
if err != nil {
return nil, fmt.Errorf("checkout.Conflicts: %w", err)
}
if err := c.ScanChanges(0); err != nil {
return nil, fmt.Errorf("checkout.Conflicts: %w", err)
}
rows, err := vfile.Load(c.db, int64(vid))
if err != nil {
return nil, fmt.Errorf("checkout.Conflicts: %w", err)
}
conflicted, err := c.unresolvedSparse()
if err != nil {
return nil, err
}
for _, r := range rows {
if r.IsRemoved() || r.IsLink > 0 || !r.ContentChanged() {
continue
}
data, present, err := c.readWorkingFile(r)
if err != nil {
return nil, fmt.Errorf("checkout.Conflicts: %w", err)
}
if present && merge.HasConflictMarkers(data) {
conflicted = append(conflicted, r.Pathname)
}
}
sort.Strings(conflicted)
return conflicted, nil
}