package daemon import ( "errors" "os" "regexp" "sort" "sync" "time" ) // DefaultOverlayIdleTTL is the default per-session idle expiry for // editor-buffer overlays. Raised from the original 5-minute draft to // 30 minutes after field feedback: realistic coding sessions // (deliberation, debugger pauses, IDE refactor wizards) commonly // exceed five minutes without producing a Push, and the read-time // LastUsed bump in SnapshotFor already keeps the session alive for // any tool call. Thirty minutes is comfortably above typical // inactivity gaps and well below the point where a forgotten // disconnected client would meaningfully pin memory. const DefaultOverlayIdleTTL = 30 * time.Minute // MainBranchName is the implicit base branch every session starts on. // All existing overlay tools (push / list / delete / drop / // compare_with_overlay / preview_edit / simulate_chain) operate // against the active branch — and unless the caller explicitly // switches, that branch is `main`. Existing callers that never touch // the branch tools see one branch with this name and never need to // know about branches at all. const MainBranchName = "main" // branchNamePattern bounds branch names to a small, predictable // alphabet. Stays well clear of shell-meta and path-separator // characters so a branch name can safely embed in tool responses, // log lines, or future on-disk representations. var branchNamePattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9_\-]{0,63}$`) // OverlayIdleTTLFromEnv resolves the idle-TTL setting that // `gortex server` / `gortex daemon` should construct their // OverlayManager with. Precedence: // // 1. Caller-supplied override (non-zero). // 2. `GORTEX_OVERLAY_IDLE_TTL` env var, parsed by time.ParseDuration // ("30m", "1h", "45s", etc.). // 3. DefaultOverlayIdleTTL. // // A negative parse result clamps to 0 (which means "no expiry" — // useful only for tests). Garbage env values fall back to the // default; we deliberately don't fail startup over a typo'd duration. func OverlayIdleTTLFromEnv(override time.Duration) time.Duration { if override > 0 { return override } if raw := os.Getenv("GORTEX_OVERLAY_IDLE_TTL"); raw != "" { if d, err := time.ParseDuration(raw); err == nil { if d < 0 { d = 0 } return d } } return DefaultOverlayIdleTTL } // OverlayFile is one editor-buffer override pushed by an MCP client. // The daemon (or a remote graph service over the gateway) merges // these on top of the base graph view for the duration of a session. // Iteration 1 only models text files; binary overlays would need a // different shape and are not in scope. type OverlayFile struct { // Path is repo-relative when WorkspaceID is set, absolute // otherwise. The graph service maps it onto the repo root via // its ConfigManager. Path string `json:"path"` // Content is the in-editor text. Empty means "deletion overlay" // — the client wants the daemon to act as if the file isn't on // disk, even if it actually is. The daemon distinguishes this // from a real empty file by only honouring deletions when the // session declares them via OverlayPush(..., Deleted: true). Content string `json:"content"` // BaseSHA is the file's git blob SHA at the time the editor // opened it. Used by the daemon's drift-detection: if the file // on disk now hashes to a different SHA, the overlay is stale // and the daemon refuses to merge it (returns ErrOverlayDrift). // Empty disables drift-detection — useful for editor-buffer // states that exist before any save. BaseSHA string `json:"base_sha,omitempty"` // Deleted, when true, marks the overlay as a tombstone (see // Content above). Mutually exclusive with non-empty Content. Deleted bool `json:"deleted,omitempty"` } // overlayBranch is one named speculative state owned by an // OverlaySession. A session always carries the implicit `main` // branch; `overlay_fork` materialises additional branches by deep- // copying the source branch's file map. Branches share nothing // mutable after the fork instant — every Push / Delete / Drop // targets exactly one branch and leaves the rest untouched. type overlayBranch struct { Name string Parent string // empty for the implicit `main` branch Created time.Time files map[string]OverlayFile // path → overlay } // OverlaySession holds one client's pushed overlays for the duration // of an MCP session. Sessions auto-expire after IdleTTL of inactivity // so a crashed client doesn't leak memory in the daemon. Each session // carries a set of named branches (always at least the implicit // `main` branch) and exactly one is active at any time; all existing // overlay queries route to that active branch. type OverlaySession struct { ID string WorkspaceID string Created time.Time LastUsed time.Time branches map[string]*overlayBranch active string } // activeBranch returns the active branch struct. Callers always hold // the manager's mutex when invoking this — branches mutate exclusively // under it. func (s *OverlaySession) activeBranch() *overlayBranch { if s == nil { return nil } if br, ok := s.branches[s.active]; ok { return br } // Defensive fallback: surfaces an empty `main` branch if the // active pointer somehow drifted. RegisterWithID / SwitchBranch // keep `active` in sync with branches; this guard is belt-and- // braces for future refactors. if br, ok := s.branches[MainBranchName]; ok { s.active = MainBranchName return br } return nil } // OverlayManager manages the per-session overlay map for the daemon. // Goroutine-safe; callers can register, push, and delete from any // goroutine. A single janitor goroutine sweeps idle sessions. type OverlayManager struct { mu sync.RWMutex sessions map[string]*OverlaySession idleTTL time.Duration } // ErrSessionNotFound is returned by OverlayManager methods that // reference an unknown session ID. The daemon translates this to // HTTP 404 on `/v1/overlay//...` endpoints. var ErrSessionNotFound = errors.New("overlay session not found") // ErrOverlayDrift is returned by OverlayPush when the supplied // BaseSHA disagrees with the file's current on-disk SHA. The client // is expected to re-read the file and resubmit a fresh overlay; the // daemon refuses to fold a known-stale overlay into queries because // merge artefacts (lines moved by a sibling tool's edit) would // surface as wrong-line errors that look like graph bugs. var ErrOverlayDrift = errors.New("overlay base SHA mismatch — re-read and resubmit") // ErrBranchNotFound is returned by branch tools when the named // branch does not exist on the session. The MCP surface translates // this into a structured tool error with the branch name embedded. var ErrBranchNotFound = errors.New("overlay branch not found") // ErrBranchExists is returned by Fork when the destination name is // already taken. Branch names are unique per session. var ErrBranchExists = errors.New("overlay branch already exists") // ErrInvalidBranchName is returned when the caller passes a name // that doesn't match branchNamePattern. The MCP surface echoes the // pattern back so callers can correct the request locally. var ErrInvalidBranchName = errors.New("invalid overlay branch name (alphanumeric + dash/underscore, max 64 chars)") // ErrCannotDropActiveBranch is returned by DropBranch when the // caller targets the currently active branch. The caller must // SwitchBranch off it first. var ErrCannotDropActiveBranch = errors.New("cannot drop the active overlay branch — switch first") // ErrCannotDropMainBranch refuses to delete the implicit `main` // branch. The session's base must always exist; dropping the whole // session is the right move when the caller wants to walk away. var ErrCannotDropMainBranch = errors.New("cannot drop the implicit main overlay branch — drop the session instead") // ErrMergeConflict signals that a non-forced merge tried to fold // branches that disagree on at least one file's content. The MCP // surface returns the conflicting paths to the caller so they can // resolve and retry with force:true. var ErrMergeConflict = errors.New("overlay merge conflict — pass force:true to override (last-writer-wins)") // NewOverlayManager creates a manager with the given idle TTL. ttl // <= 0 disables expiry (useful for tests that want deterministic // session behaviour). func NewOverlayManager(idleTTL time.Duration) *OverlayManager { return &OverlayManager{ sessions: make(map[string]*OverlaySession), idleTTL: idleTTL, } } // Register starts a new session and returns its ID. The workspace // slug is captured at register time; later pushes that target a // different workspace are rejected (one session = one workspace, // per the overlay model). func (m *OverlayManager) Register(workspaceID string) string { id := newSessionID() _ = m.RegisterWithID(id, workspaceID) return id } // ErrSessionExists is returned by RegisterWithID when the caller-supplied // session ID is already known. The MCP-side overlay tools rely on this // error to detect "register-called-twice" races; HTTP callers don't see // it because Register generates fresh IDs. var ErrSessionExists = errors.New("overlay session already exists") // RegisterWithID registers a session under a caller-chosen ID. This is // the path the MCP `overlay_register` tool takes — it binds the overlay // session to the MCP session ID so the query path can find the overlay // snapshot from the request context without an extra lookup. The HTTP // register handler also routes through here when its body includes an // explicit `session_id`. // // Returns ErrSessionExists when the ID is already in use. Idempotent // re-registration (same workspaceID) is treated as a no-op: the client // may safely retry register without first checking. func (m *OverlayManager) RegisterWithID(sessionID, workspaceID string) error { if sessionID == "" { return errors.New("overlay session id is required") } now := time.Now() m.mu.Lock() defer m.mu.Unlock() if existing, ok := m.sessions[sessionID]; ok { if existing.WorkspaceID == workspaceID { existing.LastUsed = now return nil } return ErrSessionExists } m.sessions[sessionID] = &OverlaySession{ ID: sessionID, WorkspaceID: workspaceID, Created: now, LastUsed: now, branches: map[string]*overlayBranch{ MainBranchName: { Name: MainBranchName, Created: now, files: make(map[string]OverlayFile), }, }, active: MainBranchName, } return nil } // Has reports whether a session is currently registered. Used by the // MCP tool dispatcher to decide whether to skip the per-request apply // pass (no session → no work). Cheap O(1) read under the read lock. func (m *OverlayManager) Has(sessionID string) bool { if m == nil || sessionID == "" { return false } m.mu.RLock() defer m.mu.RUnlock() _, ok := m.sessions[sessionID] return ok } // FileCount returns the number of overlay files attached to a session's // active branch, 0 if the session is unknown. Cheap fast-path: lets the // dispatcher skip the apply pass when a session is registered but its // active branch is empty. func (m *OverlayManager) FileCount(sessionID string) int { if m == nil || sessionID == "" { return 0 } m.mu.RLock() defer m.mu.RUnlock() sess, ok := m.sessions[sessionID] if !ok { return 0 } br := sess.activeBranch() if br == nil { return 0 } return len(br.files) } // Touch refreshes a session's idle timer without altering its overlay // files. Called by the MCP `overlay_keepalive` tool so an editor can // explicitly extend the lease without re-pushing buffer content. // Returns ErrSessionNotFound when the session doesn't exist so the // keepalive tool can surface "session lost; please re-register". func (m *OverlayManager) Touch(sessionID string) error { if m == nil { return ErrSessionNotFound } m.mu.Lock() defer m.mu.Unlock() sess, ok := m.sessions[sessionID] if !ok { return ErrSessionNotFound } sess.LastUsed = time.Now() return nil } // IdleTTL returns the configured idle expiry duration. Exposed so the // overlay_list tool can compute and surface an `expires_at` hint to // editor extensions that want to schedule a keepalive proactively. // Zero means "no expiry" (test-mode). func (m *OverlayManager) IdleTTL() time.Duration { if m == nil { return 0 } return m.idleTTL } // SessionStatus is the per-session liveness snapshot reported through // overlay_list. Callers compare `IdleSeconds` to `IdleTTLSeconds` to // decide when to push a keepalive; `ExpiresAt` (RFC3339) is the // concrete wall-clock instant the janitor would reap the session if // no further activity arrived. ActiveBranch carries the session's // currently-active branch name (always non-empty for a live session). type SessionStatus struct { WorkspaceID string Created time.Time LastUsed time.Time IdleSeconds float64 IdleTTLSeconds float64 ExpiresAt time.Time // zero when idleTTL <= 0 ActiveBranch string } // StatusFor returns liveness metadata for a session without touching // LastUsed (unlike SnapshotFor, which is a read-with-bump). Used by // overlay_list to render expiry hints without resetting the timer // every time the editor polls. func (m *OverlayManager) StatusFor(sessionID string) (SessionStatus, error) { if m == nil { return SessionStatus{}, ErrSessionNotFound } m.mu.RLock() defer m.mu.RUnlock() sess, ok := m.sessions[sessionID] if !ok { return SessionStatus{}, ErrSessionNotFound } st := SessionStatus{ WorkspaceID: sess.WorkspaceID, Created: sess.Created, LastUsed: sess.LastUsed, IdleSeconds: time.Since(sess.LastUsed).Seconds(), IdleTTLSeconds: m.idleTTL.Seconds(), ActiveBranch: sess.active, } if m.idleTTL > 0 { st.ExpiresAt = sess.LastUsed.Add(m.idleTTL) } return st, nil } // SnapshotFor returns the overlay files for a session's active branch // in a stable, path-sorted order along with the workspace slug // captured at register time. Returns ErrSessionNotFound when the // session doesn't exist. The returned slice never aliases the // manager's internal map; callers can mutate it freely. // // This is the preferred read API for the query path: the deterministic // ordering means two overlay-active requests with the same overlay set // touch the same paths in the same order, which simplifies test // assertions and makes drift errors point at the same path on retry. func (m *OverlayManager) SnapshotFor(sessionID string) (workspace string, files []OverlayFile, err error) { if m == nil { return "", nil, ErrSessionNotFound } // Promoted to write lock so we can refresh LastUsed alongside // the snapshot copy: every tool-call view-build flows through // here, and that activity must reset the idle timer. Without // this, a session that only queries (no further Push) would // trip the TTL while in active use. The cost is one extra // mutex promotion per overlay-active tool call — negligible // against the parse work the view builder is about to do. m.mu.Lock() defer m.mu.Unlock() sess, ok := m.sessions[sessionID] if !ok { return "", nil, ErrSessionNotFound } sess.LastUsed = time.Now() br := sess.activeBranch() if br == nil { return sess.WorkspaceID, nil, nil } out := make([]OverlayFile, 0, len(br.files)) for _, f := range br.files { out = append(out, f) } sort.Slice(out, func(i, j int) bool { return out[i].Path < out[j].Path }) return sess.WorkspaceID, out, nil } // Push attaches one overlay file to a session's active branch. // Workspace mismatch (the session was registered for workspace X but // the push targets Y) returns an error: a session is supposed to be // a coherent view over one workspace's repos. // // driftCheck is a callback the manager invokes to verify BaseSHA // against the on-disk file. The daemon supplies it; tests can pass // nil to skip the check. If driftCheck returns false and overlay // has a non-empty BaseSHA, Push fails with ErrOverlayDrift. func (m *OverlayManager) Push(sessionID string, overlay OverlayFile, driftCheck func(path, sha string) bool) error { if overlay.Path == "" { return errors.New("overlay path is required") } if overlay.Deleted && overlay.Content != "" { return errors.New("overlay cannot be both deleted and have content") } if overlay.BaseSHA != "" && driftCheck != nil { if !driftCheck(overlay.Path, overlay.BaseSHA) { return ErrOverlayDrift } } m.mu.Lock() defer m.mu.Unlock() sess, ok := m.sessions[sessionID] if !ok { return ErrSessionNotFound } br := sess.activeBranch() if br == nil { return ErrSessionNotFound } br.files[overlay.Path] = overlay sess.LastUsed = time.Now() return nil } // Delete removes one overlay file from a session's active branch by // path. Returns ErrSessionNotFound when the session doesn't exist. func (m *OverlayManager) Delete(sessionID, path string) error { m.mu.Lock() defer m.mu.Unlock() sess, ok := m.sessions[sessionID] if !ok { return ErrSessionNotFound } br := sess.activeBranch() if br == nil { return ErrSessionNotFound } delete(br.files, path) sess.LastUsed = time.Now() return nil } // Drop terminates the session and discards every branch and overlay // it held. Idempotent — dropping an unknown session is a no-op. func (m *OverlayManager) Drop(sessionID string) { m.mu.Lock() defer m.mu.Unlock() delete(m.sessions, sessionID) } // Files returns a snapshot of every overlay attached to a session's // active branch (no live aliasing — the returned map can be mutated // freely). ErrSessionNotFound when the session doesn't exist. func (m *OverlayManager) Files(sessionID string) (map[string]OverlayFile, error) { m.mu.RLock() defer m.mu.RUnlock() sess, ok := m.sessions[sessionID] if !ok { return nil, ErrSessionNotFound } br := sess.activeBranch() if br == nil { return map[string]OverlayFile{}, nil } out := make(map[string]OverlayFile, len(br.files)) for k, v := range br.files { out[k] = v } return out, nil } // SessionWorkspace returns the workspace slug captured at Register. // ErrSessionNotFound when the session doesn't exist. func (m *OverlayManager) SessionWorkspace(sessionID string) (string, error) { m.mu.RLock() defer m.mu.RUnlock() sess, ok := m.sessions[sessionID] if !ok { return "", ErrSessionNotFound } return sess.WorkspaceID, nil } // SweepIdle drops sessions whose LastUsed is older than IdleTTL. // Returns the count of dropped sessions for telemetry. Safe to call // from a single janitor goroutine on a ticker. func (m *OverlayManager) SweepIdle() int { if m.idleTTL <= 0 { return 0 } cutoff := time.Now().Add(-m.idleTTL) m.mu.Lock() defer m.mu.Unlock() dropped := 0 for id, sess := range m.sessions { if sess.LastUsed.Before(cutoff) { delete(m.sessions, id) dropped++ } } return dropped } // StartJanitor launches the background sweeper that enforces IdleTTL. // Without it SweepIdle never runs and a session whose proxy vanished // without an observed disconnect pins its pushed buffers for the // daemon's lifetime. interval <= 0 derives a default from the TTL // (TTL/4, floored at one minute). onSweep, when non-nil, is invoked // with the dropped count after any sweep that removed sessions — // telemetry stays the caller's concern. The returned stop func // terminates the goroutine and is safe to call more than once. A // manager with expiry disabled (idleTTL <= 0) starts no goroutine // and returns a no-op stop. func (m *OverlayManager) StartJanitor(interval time.Duration, onSweep func(dropped int)) (stop func()) { if m.idleTTL <= 0 { return func() {} } if interval <= 0 { interval = m.idleTTL / 4 if interval < time.Minute { interval = time.Minute } } done := make(chan struct{}) var once sync.Once go func() { t := time.NewTicker(interval) defer t.Stop() for { select { case <-t.C: if dropped := m.SweepIdle(); dropped > 0 && onSweep != nil { onSweep(dropped) } case <-done: return } } }() return func() { once.Do(func() { close(done) }) } } // --------------------------------------------------------------------------- // Branching surface: fork, list, switch, merge, drop_branch. // --------------------------------------------------------------------------- // ValidateBranchName returns ErrInvalidBranchName when the supplied // name does not satisfy the [A-Za-z0-9][A-Za-z0-9_-]{0,63} pattern. // Exposed so the MCP layer can fail fast with a precise message // before locking the manager. func ValidateBranchName(name string) error { if !branchNamePattern.MatchString(name) { return ErrInvalidBranchName } return nil } // BranchInfo is the per-branch row returned by Branches. type BranchInfo struct { Name string Active bool FileCount int BaseSHACount int Parent string Created time.Time } // Branches lists every branch a session holds, including the implicit // `main` branch. The returned slice is sorted by name (with the // active branch always first in the response when the caller wants // to surface it). Returns ErrSessionNotFound when the session doesn't // exist. func (m *OverlayManager) Branches(sessionID string) ([]BranchInfo, error) { if m == nil { return nil, ErrSessionNotFound } m.mu.RLock() defer m.mu.RUnlock() sess, ok := m.sessions[sessionID] if !ok { return nil, ErrSessionNotFound } out := make([]BranchInfo, 0, len(sess.branches)) for name, br := range sess.branches { baseShas := 0 for _, f := range br.files { if f.BaseSHA != "" { baseShas++ } } out = append(out, BranchInfo{ Name: name, Active: name == sess.active, FileCount: len(br.files), BaseSHACount: baseShas, Parent: br.Parent, Created: br.Created, }) } sort.Slice(out, func(i, j int) bool { // Active branch first, then alphabetical — keeps the JSON // payload deterministic AND foregrounds the most relevant // entry for diagnostic dumps. if out[i].Active != out[j].Active { return out[i].Active } return out[i].Name < out[j].Name }) return out, nil } // ActiveBranch returns the name of the session's active branch. // Returns ErrSessionNotFound when the session doesn't exist. func (m *OverlayManager) ActiveBranch(sessionID string) (string, error) { if m == nil { return "", ErrSessionNotFound } m.mu.RLock() defer m.mu.RUnlock() sess, ok := m.sessions[sessionID] if !ok { return "", ErrSessionNotFound } return sess.active, nil } // ForkOptions controls the Fork call. Name is required; From defaults // to the session's active branch; Activate makes the new branch the // session's active branch (otherwise the caller must SwitchBranch // explicitly to use it). type ForkOptions struct { Name string From string // empty → session's active branch Activate bool } // ForkResult carries the post-fork state so the MCP surface can echo // it back to the caller without a separate Branches call. type ForkResult struct { Branch string Parent string FileCount int Active bool } // Fork clones the source branch into a new named branch on the same // session. The new branch starts with a deep copy of the source's // file map; no shared mutable state remains after the fork instant. // Returns ErrBranchExists when the destination name is already taken. func (m *OverlayManager) Fork(sessionID string, opts ForkOptions) (ForkResult, error) { if m == nil { return ForkResult{}, ErrSessionNotFound } if err := ValidateBranchName(opts.Name); err != nil { return ForkResult{}, err } m.mu.Lock() defer m.mu.Unlock() sess, ok := m.sessions[sessionID] if !ok { return ForkResult{}, ErrSessionNotFound } if _, ok := sess.branches[opts.Name]; ok { return ForkResult{}, ErrBranchExists } source := opts.From if source == "" { source = sess.active } src, ok := sess.branches[source] if !ok { return ForkResult{}, ErrBranchNotFound } cloneFiles := make(map[string]OverlayFile, len(src.files)) for k, v := range src.files { cloneFiles[k] = v } now := time.Now() sess.branches[opts.Name] = &overlayBranch{ Name: opts.Name, Parent: source, Created: now, files: cloneFiles, } if opts.Activate { sess.active = opts.Name } sess.LastUsed = now return ForkResult{ Branch: opts.Name, Parent: source, FileCount: len(cloneFiles), Active: sess.active == opts.Name, }, nil } // SwitchBranch flips the session's active branch. Returns // ErrBranchNotFound when the name is unknown. func (m *OverlayManager) SwitchBranch(sessionID, name string) error { if m == nil { return ErrSessionNotFound } if err := ValidateBranchName(name); err != nil { return err } m.mu.Lock() defer m.mu.Unlock() sess, ok := m.sessions[sessionID] if !ok { return ErrSessionNotFound } if _, ok := sess.branches[name]; !ok { return ErrBranchNotFound } sess.active = name sess.LastUsed = time.Now() return nil } // DropBranch deletes a named branch. Refuses to drop the active // branch (caller must SwitchBranch first) and refuses to drop the // implicit `main` branch (which would leave the session in an // inconsistent state — dropping the whole session is the right move // when the caller wants to walk away). func (m *OverlayManager) DropBranch(sessionID, name string) error { if m == nil { return ErrSessionNotFound } if name == MainBranchName { return ErrCannotDropMainBranch } if err := ValidateBranchName(name); err != nil { return err } m.mu.Lock() defer m.mu.Unlock() sess, ok := m.sessions[sessionID] if !ok { return ErrSessionNotFound } if name == sess.active { return ErrCannotDropActiveBranch } if _, ok := sess.branches[name]; !ok { return ErrBranchNotFound } delete(sess.branches, name) sess.LastUsed = time.Now() return nil } // MergeOptions controls MergeBranches. From and Into name the source // and destination branches; ToDisk redirects the merge target from // `Into` to the filesystem (writing each From file with the supplied // writer callback). Force flips conflict policy from "refuse" to // "last-writer-wins" — without it, any path that exists in both // branches with different content blocks the merge. type MergeOptions struct { From string Into string ToDisk bool Force bool } // DiskWriteFn is the callback MergeBranches invokes for each From // file when ToDisk is true. The implementer is responsible for the // drift check (re-hash the on-disk content, compare to BaseSHA, // return ErrOverlayDrift on mismatch) and the atomic write. Keeping // the daemon free of filesystem and SHA-hashing concerns avoids a // circular dependency on `internal/mcp` helpers; the MCP layer // injects its existing `gitBlobSHA` + `agents.AtomicWriteFile` // helpers as the writer. type DiskWriteFn func(file OverlayFile) error // MergeResult is the structured outcome of MergeBranches. The MCP // surface echoes it back to the caller; the `Conflicts` slice // carries the paths that disagreed when Force was false (the merge // aborted) or that were last-writer-wins-resolved when Force was true. type MergeResult struct { Merged int Conflicts []string DroppedBranch bool ResolvedByForce bool } // MergeBranches folds opts.From into opts.Into (or onto disk when // opts.ToDisk is true). Conflict policy: a "conflict" is a path that // exists in both branches with different content/deleted/baseSha // tuples. Without Force, conflicts abort the merge and the result // reports the conflicting paths. With Force, conflicts are // last-writer-wins: opts.From wins (consistent with the source- // branch-driven model) and the response notes the resolution. // // When ToDisk is true, opts.From is written to the filesystem via the // caller-supplied DiskWriteFn (which is expected to enforce the // BaseSHA drift guard) and opts.From is dropped from the session on // success. ToDisk + Force is meaningful: with drift-check enforcement // inside the writer, force only affects branch-vs-branch conflict // resolution; the writer itself is the disk-drift authority. func (m *OverlayManager) MergeBranches(sessionID string, opts MergeOptions, write DiskWriteFn) (MergeResult, error) { if m == nil { return MergeResult{}, ErrSessionNotFound } if opts.From == "" { return MergeResult{}, errors.New("merge: from branch is required") } if err := ValidateBranchName(opts.From); err != nil { return MergeResult{}, err } if !opts.ToDisk { into := opts.Into if into == "" { into = MainBranchName } if err := ValidateBranchName(into); err != nil { return MergeResult{}, err } opts.Into = into if opts.From == opts.Into { return MergeResult{}, errors.New("merge: from and into are the same branch") } } m.mu.Lock() defer m.mu.Unlock() sess, ok := m.sessions[sessionID] if !ok { return MergeResult{}, ErrSessionNotFound } src, ok := sess.branches[opts.From] if !ok { return MergeResult{}, ErrBranchNotFound } if opts.ToDisk { if write == nil { return MergeResult{}, errors.New("merge: disk write callback is required for to_disk merges") } // Iterate paths in deterministic order so a drift error // always points at the same path on retry. paths := make([]string, 0, len(src.files)) for p := range src.files { paths = append(paths, p) } sort.Strings(paths) merged := 0 for _, p := range paths { f := src.files[p] if err := write(f); err != nil { // Surface drift unchanged so callers can pattern- // match the existing ErrOverlayDrift string. return MergeResult{Merged: merged}, err } merged++ } delete(sess.branches, opts.From) if sess.active == opts.From { sess.active = MainBranchName } sess.LastUsed = time.Now() return MergeResult{Merged: merged, DroppedBranch: true}, nil } dst, ok := sess.branches[opts.Into] if !ok { return MergeResult{}, ErrBranchNotFound } // Walk From in deterministic order so the conflict / merge // list and the response are stable across runs. paths := make([]string, 0, len(src.files)) for p := range src.files { paths = append(paths, p) } sort.Strings(paths) var conflicts []string for _, p := range paths { fromFile := src.files[p] if existing, ok := dst.files[p]; ok && !overlayFilesEqual(existing, fromFile) { conflicts = append(conflicts, p) } } if len(conflicts) > 0 && !opts.Force { return MergeResult{Conflicts: conflicts}, ErrMergeConflict } merged := 0 for _, p := range paths { fromFile := src.files[p] dst.files[p] = fromFile merged++ } sess.LastUsed = time.Now() return MergeResult{ Merged: merged, Conflicts: conflicts, ResolvedByForce: len(conflicts) > 0, }, nil } // overlayFilesEqual is the conflict-policy primitive: two files // disagree when any user-visible field differs. BaseSHA is included // because two branches that opened the same disk file at different // times may have stale SHAs that, if silently merged, would carry // a known-stale baseline into the destination. func overlayFilesEqual(a, b OverlayFile) bool { return a.Path == b.Path && a.Content == b.Content && a.BaseSHA == b.BaseSHA && a.Deleted == b.Deleted } // PushToBranch attaches one overlay file to a *specific* branch // without changing the session's active branch. The single-branch // Push API operates against the active branch (backward compat); // this variant is for callers — primarily test harnesses and // future concurrent agents — that need to mutate sibling branches // without disturbing the active pointer. Conflict semantics with // the active-branch view are identical: a push merely overwrites // the per-branch file map under the manager's mutex. func (m *OverlayManager) PushToBranch(sessionID, branchName string, overlay OverlayFile, driftCheck func(path, sha string) bool) error { if overlay.Path == "" { return errors.New("overlay path is required") } if overlay.Deleted && overlay.Content != "" { return errors.New("overlay cannot be both deleted and have content") } if overlay.BaseSHA != "" && driftCheck != nil { if !driftCheck(overlay.Path, overlay.BaseSHA) { return ErrOverlayDrift } } m.mu.Lock() defer m.mu.Unlock() sess, ok := m.sessions[sessionID] if !ok { return ErrSessionNotFound } br, ok := sess.branches[branchName] if !ok { return ErrBranchNotFound } br.files[overlay.Path] = overlay sess.LastUsed = time.Now() return nil } // DeleteFromBranch removes one overlay file from a specific branch. // Companion to PushToBranch; does not change the active pointer. func (m *OverlayManager) DeleteFromBranch(sessionID, branchName, path string) error { m.mu.Lock() defer m.mu.Unlock() sess, ok := m.sessions[sessionID] if !ok { return ErrSessionNotFound } br, ok := sess.branches[branchName] if !ok { return ErrBranchNotFound } delete(br.files, path) sess.LastUsed = time.Now() return nil } // FilesForBranch returns the file map for a specific branch. Used by // compare_branches (which has to read both branches in one shot to // compute the delta). The returned slice never aliases the manager's // internal state. Returns ErrBranchNotFound when the branch is // unknown. func (m *OverlayManager) FilesForBranch(sessionID, branchName string) ([]OverlayFile, error) { if m == nil { return nil, ErrSessionNotFound } m.mu.Lock() defer m.mu.Unlock() sess, ok := m.sessions[sessionID] if !ok { return nil, ErrSessionNotFound } br, ok := sess.branches[branchName] if !ok { return nil, ErrBranchNotFound } sess.LastUsed = time.Now() out := make([]OverlayFile, 0, len(br.files)) for _, f := range br.files { out = append(out, f) } sort.Slice(out, func(i, j int) bool { return out[i].Path < out[j].Path }) return out, nil }