Artifact c88ba626ceff9b3c0620f970be5bd6f4af73913c9c00ad7863d8a3c5b78f2860:
- File
internal/checkout/merge.go
— part of check-in
[78483726d9]
at
2026-10-23 01:41:07
on branch feat/horizon-cones
— feat(sync): ask the list-form merge base whenever a merge crosses
The re-pinned draft's 6.2.4 query is a primary and one or two secondaries, `pragma horizon-mergebase <primary> <secondary>...`, and the answer repeats the query in order and appends Fossil's pivot over the set. A two-hash query was answered as the base of a pair, so a merge into a checkout with a merge already pending could be told a base Fossil's merge would not choose. The origin now takes the list (sync.MergeBaseQuery), answers nothing when it lacks any named check-in or is asked more secondaries than a six-token card can repeat, and the client takes only an answer that repeats its query.
5.5 now has the clone ask whenever either side's ancestry crosses the horizon, not only when its own walk does: over part of the graph the common ancestor found need not be the nearest. MergeContext, given a peer, checks the ancestry of the merged-in version, the checkout and its pending plain merges (content.AncestryCrosses), asks the pivot of the merged-in version over the checkout and those merges, and merges over the answer, falling back to Merge when the origin does not answer, as zit's merge does. With two merges already pending the query cannot carry them, and asking without them would name another base, so the merge is refused before anything changes.
The deepen point's query names the target over C, as zit asks it. (user: [email protected], size: 38491) [annotate] [blame] [check-ins using] [more...]
package checkout import ( "context" "database/sql" "errors" "fmt" "os" "path" "path/filepath" "sort" "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 // MergeRenamed: the merged-in version renamed the file and changed // nothing else the merge takes, so it was only renamed. MergeRenamed ) // 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 // Baseline, when set, is the common ancestor to diff against instead of // the one Merge finds, as fossil's merge --baseline; it must be held. Baseline libfossil.FslID } // MergeFile reports one file Merge acted on. Name is the file's name in // the checkout before the merge, or in the merged-in version for an added // file. 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 RenamedTo string // the name the merge renamed the file to, if it did } // 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 followed as fossil's merge follows them (merge.FollowRenames): // a file is matched across the versions by its name in each, so a change // made under one name is merged into the file under another. A file the // merged-in version renamed and the checkout did not is renamed, and keeps // whatever changes the checkout made; a file the checkout renamed keeps its // name. Where fossil would rename a file over one the checkout tracks and // keeps, or over an untracked file, Merge refuses, as it does an add. // // 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 c.cone != nil { var pending bool if err := c.db.QueryRow("SELECT EXISTS(SELECT 1 FROM vmerge WHERE id<=0)").Scan(&pending); err != nil { return MergeResult{}, fmt.Errorf("checkout.Merge: pending merge: %w", err) } if pending { return MergeResult{}, fmt.Errorf("checkout.Merge: a merge is already pending; resolve and commit it, or undo it") } } if opts.Version == vid { return MergeResult{}, nil } base, err := c.mergeBase(vid, opts) if err != nil && !errors.Is(err, merge.ErrNoCommonAncestor) { return MergeResult{}, fmt.Errorf("checkout.Merge: common ancestor: %w", err) } pivot := base.RID // draft-fossil-horizon-00 §5.5: never merge against a base the store // does not hold, nor one found by a walk that crossed the horizon, and // name the horizon when that is why. if cerr := content.MergeBaseCrossing(c.repo.DB(), opts.Version, pivot, base.Unheld); cerr != nil { return MergeResult{}, fmt.Errorf("checkout.Merge: %w", cerr) } 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, baseline: opts.Baseline > 0} if err := c.planMerge(&plan, opts.Strategy); err != nil { return MergeResult{}, fmt.Errorf("checkout.Merge: %w", err) } if err := c.checkMergeTargets(plan); 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 } // mergeBase is the common ancestor the merge diffs against, as fossil's // merge_cmd chooses it (merge.c, pivot_set_primary(mid) through pid = // pivot_find(0)): the pivot of the merged-in version, primary, and // MergeSecondaries. opts.Baseline overrides it, as --baseline does. The name // pivot renames are followed from is merge.FollowRenames'. func (c *Checkout) mergeBase(vid libfossil.FslID, opts MergeOpts) (merge.Pivot, error) { if opts.Baseline > 0 { return merge.Pivot{RID: opts.Baseline}, nil } secondaries, err := c.mergeSecondaries(vid) if err != nil { return merge.Pivot{}, err } return merge.FindPivot(context.Background(), c.repo, merge.PivotQuery{Primary: opts.Version, Secondaries: secondaries}) } // MergeSecondaries is the checkout's side of the next merge's pivot, as // fossil's merge_cmd sets it: the checkout's version, then every check-in // already merged into it, the vmerge rows with id 0. A pending cherry-pick, // backout or integrate is not one (draft-fossil-horizon-00 §6.2.4). func (c *Checkout) MergeSecondaries() ([]libfossil.FslID, error) { if c == nil { panic("checkout.MergeSecondaries: nil *Checkout") } vid, _, err := c.Version() if err != nil { return nil, fmt.Errorf("checkout.MergeSecondaries: %w", err) } if vid <= 0 { return nil, fmt.Errorf("checkout.MergeSecondaries: nothing is checked out") } return c.mergeSecondaries(vid) } func (c *Checkout) mergeSecondaries(vid libfossil.FslID) ([]libfossil.FslID, error) { secondaries := []libfossil.FslID{vid} rows, err := c.db.Query("SELECT merge FROM vmerge WHERE id=0") if err != nil { return nil, fmt.Errorf("pending merges: %w", err) } defer rows.Close() for rows.Next() { var rid int64 if err := rows.Scan(&rid); err != nil { return nil, fmt.Errorf("pending merges: %w", err) } secondaries = append(secondaries, libfossil.FslID(rid)) } if err := rows.Err(); err != nil { return nil, fmt.Errorf("pending merges: %w", err) } return secondaries, nil } // mergePlan is a merge worked out in full before anything is changed. type mergePlan struct { vid, pivot, mid libfossil.FslID baseline bool // pivot was given, not found rows []vfile.Row // the checkout's files 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 } plan.rows = rows ancestor, err := c.mergeSides(plan.pivot) if err != nil { return err } merged, err := c.mergeSides(plan.mid) if err != nil { return err } local := make([]merge.LocalFile, len(rows)) for i, r := range rows { local[i] = merge.LocalFile{ Name: r.Pathname, Orig: r.RenamedFrom(), Added: r.IsAdded() || r.Chnged == 3 || r.Chnged == 5, } } matches, err := merge.FollowRenames(context.Background(), c.repo, merge.RenameQuery{ V: plan.vid, P: plan.pivot, M: plan.mid, Baseline: plan.baseline, Local: local, Pivot: sortedNames(ancestor), Merged: sortedNames(merged), }) if err != nil { return err } for _, match := range matches { var row *vfile.Row if match.Local >= 0 { row = &rows[match.Local] } p, m := ancestor[match.Pivot], merged[match.Merged] kind := classifyMerge(row, p, m) renameTo := mergeRenameTo(row, match, p, m) if kind == mergeSkip && renameTo == "" { continue } name := match.Pivot switch { case row != nil: name = row.Pathname case m.rid > 0: name = match.Merged } var step mergeStep if kind == mergeSkip { step = c.planRenameOnly(*row, p, m) } else if step, err = c.planStep(name, kind, row, p, m, choose); err != nil { return err } step.file.RenamedTo = renameTo plan.steps = append(plan.steps, step) } sort.Slice(plan.steps, func(i, j int) bool { return plan.steps[i].file.Name < plan.steps[j].file.Name }) return nil } // mergeRenameTo is the name the merge renames the checkout's file to, or // "": a file the merged-in version renamed and the checkout did not, as // fossil's merge picks them (fnp=fn AND fnm!=fnp, every version having it). func mergeRenameTo(row *vfile.Row, match merge.FileMatch, p, m mergeSide) string { if row == nil || p.rid <= 0 || m.rid <= 0 { return "" } if match.Pivot != row.Pathname || match.Merged == match.Pivot { return "" } return match.Merged } // planRenameOnly is the step for a file the merge only renames. func (c *Checkout) planRenameOnly(row vfile.Row, p, m mergeSide) mergeStep { return mergeStep{ file: MergeFile{Name: row.Pathname, Action: MergeRenamed}, row: row, merged: m, exe: mergedExe(row, p, m), recordOnly: !c.covers(row.Pathname), pivotRID: p.rid, } } // sortedNames is a version's file names, sorted. func sortedNames(sides map[string]mergeSide) []string { names := make([]string, 0, len(sides)) for name := range sides { 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 { if row != nil && !row.IsRemoved() && libfossil.FslID(row.MRID) == m.rid && (row.IsExe > 0) == m.exe && (row.IsLink > 0) == m.link { step.file.Action = MergeKept return step, nil } 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(plan mergePlan) error { steps := plan.steps var unresolved []string for _, s := range steps { if s.recordOnly && s.file.Action == MergeConflict { unresolved = append(unresolved, s.file.Name) } } if len(unresolved) > 0 { return fmt.Errorf("unresolved both-changed paths require widening and re-running the merge: %w", c.outsideCone("Merge", unresolved)) } if err := c.checkMergeRenames(plan); err != nil { return err } 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 } // checkMergeRenames refuses a rename the merge cannot carry out without // destroying something: onto a name the checkout tracks and keeps, or onto // a file on disk that no step moves or deletes first (fossil overwrites // either, relying on its undo copy). A rename into or out of a sparse cone // is refused too: the file would change between materialized and not. func (c *Checkout) checkMergeRenames(plan mergePlan) error { vacated := map[string]bool{} var outside []string for _, s := range plan.steps { if s.file.RenamedTo != "" || s.file.Action == MergeDeleted { vacated[s.file.Name] = true } if s.file.RenamedTo != "" && (!c.covers(s.file.Name) || !c.covers(s.file.RenamedTo)) { outside = append(outside, s.file.Name) } } if len(outside) > 0 { return fmt.Errorf("renames across the sparse cone require widening and re-running the merge: %w", c.outsideCone("Merge", outside)) } tracked := make(map[string]vfile.Row, len(plan.rows)) for _, r := range plan.rows { tracked[r.Pathname] = r } for _, s := range plan.steps { to := s.file.RenamedTo if to == "" || vacated[to] { continue } if r, ok := tracked[to]; ok && !r.IsRemoved() { return fmt.Errorf("merge would rename %s over %s, which this checkout tracks", s.file.Name, to) } if err := c.checkMergeTarget(to); err != nil { return err } full, err := c.safePath(to) if err != nil { return err } if _, err := c.env.Storage.Stat(full); err == nil { return fmt.Errorf("merge would rename %s over untracked file %s; move it aside first", s.file.Name, to) } else if !errors.Is(err, os.ErrNotExist) { return fmt.Errorf("stat %s: %w", full, err) } } 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 seen := map[string]bool{} touch := func(name string) { if !seen[name] { seen[name] = true names = append(names, name) } } for _, s := range plan.steps { if s.changesDisk() || s.file.RenamedTo != "" { touch(s.file.Name) } if s.file.RenamedTo != "" { touch(s.file.RenamedTo) } } 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) } 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) } } if err := c.applyMergeRenames(tx, plan); err != nil { return 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: // 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 _, 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 MergeRenamed: return nil // applyMergeRenames 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)) } } // applyMergeRenames renames the files the merge renames, after every other // step has written them under their old names, as fossil's merge does // after its updates, merges and deletions. func (c *Checkout) applyMergeRenames(tx *sql.Tx, plan mergePlan) error { var renamed []mergeStep for _, s := range plan.steps { if s.file.RenamedTo == "" { continue } if s.row.ID <= 0 || s.recordOnly { panic("checkout.applyMergeRenames: rename of a row not in vfile: " + s.file.Name) } renamed = append(renamed, s) } if len(renamed) == 0 { return nil } if err := c.moveRenamedFiles(renamed); err != nil { return err } // Two passes, so a name another rename vacates is free when taken. A // row still holding a new name, one removed here or deleted by the // merge, is renamed aside as fossil renames it. for _, s := range renamed { if _, err := tx.Exec(`UPDATE vfile SET origname=coalesce(origname,pathname), pathname=char(0)||id WHERE id=?`, s.row.ID); err != nil { return fmt.Errorf("rename %s: %w", s.file.Name, err) } } for _, s := range renamed { to := s.file.RenamedTo if _, err := tx.Exec(`UPDATE vfile SET origname=pathname, pathname=pathname||' (overwritten by rename)' WHERE vid=? AND pathname=?`, int64(plan.vid), to); err != nil { return fmt.Errorf("rename %s: %w", s.file.Name, err) } if _, err := tx.Exec("UPDATE vfile SET pathname=? WHERE id=?", to, s.row.ID); err != nil { return fmt.Errorf("rename %s: %w", s.file.Name, err) } } return nil } // moveRenamedFiles moves each renamed step's file to its new name. Every // file is read before any is moved, so names swapped or chained between // files move as fossil's temporary copies move them. A file missing from // disk is not moved. func (c *Checkout) moveRenamedFiles(renamed []mergeStep) error { type move struct { s mergeStep data []byte from, to string link bool } disk, err := c.newDiskView() if err != nil { return err } moves := make([]move, 0, len(renamed)) for _, s := range renamed { mv := move{s: s} if mv.from, err = c.safePath(s.file.Name); err != nil { return err } if mv.to, err = c.safePath(s.file.RenamedTo); err != nil { return err } info, err := disk.Stat(mv.from) if err != nil { return err } if !isFileOrLink(info) { continue } if mv.data, err = disk.Read(mv.from, info); err != nil { return err } mv.link = vfile.IsLink(info) moves = append(moves, mv) } for _, mv := range moves { if err := disk.Remove(mv.from); err != nil { return err } } for _, mv := range moves { exe := mv.s.exe if mv.s.file.Action == MergeUpdated { exe = mv.s.merged.exe } if err := disk.Write(mv.to, mv.data, exe, mv.link); err != nil { return err } } return nil } // 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 }