Artifact 505553cfa85321336156e8754bdfae917ee3d27d032fa96ce3a8fc1bfe13e128:
- File
internal/checkout/merge.go
— part of check-in
[47f89f0798]
at
2026-10-19 14:41:42
on branch feat/submanifest-reader
— Merge branch 'feat/submanifest-reader' into feat/cones
Update's rewrite meets the sparse worktree: a file outside the cone is a record-only step, counted in OutOfCone, and never planned, scanned, checked, written, backed up as NAME-original or chmodded; reloading vfile moves its record to the target. SetCone records the mtime of each file it materializes, as Extract now does. (user: [email protected], size: 27647) [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, when an added file would overwrite an untracked file, or, in a // sparse checkout, when the merge would change a file outside the cone // (ErrOutsideCone). 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.checkMergeInCone(plan.steps); err != nil { return MergeResult{}, 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 } // checkMergeInCone refuses a merge that would change a file outside a sparse // checkout's cone. Applying such a change to the record alone, as // draft-fossil-sparse-cone-00 ยง4.4 requires, is not implemented: the merge // would otherwise write the file or lose the change, so it is refused by // naming the cone, before anything changes. func (c *Checkout) checkMergeInCone(steps []mergeStep) error { var outside []string for _, s := range steps { if !c.covers(s.file.Name) { outside = append(outside, s.file.Name) } } if len(outside) == 0 { return nil } return c.outsideCone("Merge", outside) } // 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 } // 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 := vfile.Load(c.db, int64(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} 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 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.ID <= 0 { 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) { 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 { 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") } tx, err := c.db.Begin() if err != nil { return fmt.Errorf("begin: %w", err) } if err := c.applyMergeTx(tx, plan); err != nil { return errors.Join(err, tx.Rollback()) } if err := tx.Commit(); err != nil { return fmt.Errorf("commit: %w", err) } 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 { switch s.file.Action { case MergeUpdated: if err := c.writeMergedFile(s.file.Name, s.content, s.merged.exe); err != nil { return err } _, 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: if err := c.writeMergedFile(s.file.Name, s.content, s.merged.exe); err != nil { return err } _, 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: fullPath, err := c.safePath(s.file.Name) if err != nil { return err } if err := c.removeRegularFile(fullPath); err != nil { return err } _, err = tx.Exec("UPDATE vfile SET deleted=1 WHERE id=?", s.row.ID) return err case MergeMode: if err := c.setMergedMode(s.file.Name, s.exe); err != nil { return err } _, err := tx.Exec("UPDATE vfile SET isexe=? WHERE id=?", s.exe, s.row.ID) return err case MergeMerged, MergeConflict: if err := c.writeMergedFile(s.file.Name, s.content, s.exe); 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 _, 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)) } } // 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) } // modeSetter is the optional storage method that changes a file's mode. // Storages without file modes (memory, browser) do not have it. type modeSetter interface { Chmod(path string, mode os.FileMode) error } // setMergedMode gives name's file the executable bit exe, where the storage // has file modes. func (c *Checkout) setMergedMode(name string, exe bool) error { setter, ok := c.env.Storage.(modeSetter) if !ok { return nil } fullPath, err := c.safePath(name) if err != nil { return err } info, err := c.env.Storage.Stat(fullPath) if errors.Is(err, os.ErrNotExist) { return nil } if err != nil { return fmt.Errorf("stat %s: %w", fullPath, err) } if modeIsExecutable(info.Mode()) == exe { return nil } // Fossil's file_setexe: execute wherever read is allowed, or nowhere. mode := info.Mode().Perm() &^ 0o111 if exe { mode |= (info.Mode().Perm() & 0o444) >> 2 } if err := setter.Chmod(fullPath, mode); err != nil { return fmt.Errorf("chmod %s: %w", fullPath, err) } return nil } // 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 := c.managedRows(vid) if err != nil { return nil, fmt.Errorf("checkout.Conflicts: %w", err) } var conflicted []string 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) } } return conflicted, nil // vfile.Load orders rows by pathname }