Artifact cbcd8f22e0893aea13d6091bd31f94969e797f22d4e8f474896537067557bcd2:
- File
internal/checkout/merge.go
— part of check-in
[dac73e55c9]
at
2026-10-21 11:29:11
on branch feat/submanifest-reader
— refactor(checkout): drop the unreachable record-only merge payload path
Record-only merge steps return before the content switch, and a both-changed path outside the cone is refused, so storeMergePayload had no production caller. Its tests called it directly; they go with it, and the merge-failure test keeps its vmerge assertion. (user: [email protected], size: 28590) [annotate] [blame] [check-ins using] [more...]
package checkout import ( "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 ) // 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 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 } 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 { 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(steps []mergeStep) error { 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)) } 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) } 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: // 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 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 }