Artifact 2d9217255ed634de285d5888d6fbd498bf4dd70e2e3d705be2f7fe64b87d0db3:
- File
internal/checkout/merge.go
— part of check-in
[d8458ad960]
at
2026-10-22 17:53:21
on branch feat/horizon-cones
— feat(merge): refuse a base below the structural horizon, or deepen to it
Section 5.5 forbids merging against a base the store does not hold. In a structural store the walk can reach a boundary check-in as the base, or find none where both sides' ancestry leaves the store, and the merge then failed on a phantom with an error that never named the horizon. content.MergeBaseCrossing decides the crossing for checkout merges and branch merges alike, and the error names the horizon, the base and the deepen, matching libfossil.ErrBelowHorizon.
Checkout.MergeContext is the remedy zit's merge applies: given a peer it asks the origin for the base, deepens to reach it and merges again over the real base. 'repo merge' takes the recovery flags and contacts the remote only when a merge crosses the horizon. (user: [email protected], size: 29023) [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 && !errors.Is(err, merge.ErrNoCommonAncestor) { return MergeResult{}, fmt.Errorf("checkout.Merge: common ancestor: %w", err) } // draft-fossil-horizon-00 ยง5.5: never merge against a base the store // does not hold, and name the horizon when that is why. if cerr := content.MergeBaseCrossing(c.repo.DB(), vid, opts.Version, pivot); 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} 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 }