// Package sidecar stores notevi's append-only JSONL log in a private jj change. // // The change is an anonymous direct child of root(), so it is visible and // durable in the jj repository without becoming part of project history or the // working copy. A repo-local revset alias names it and git.private-commits keeps // accidental --change pushes from publishing it. package sidecar import ( "errors" "fmt" "os" "os/exec" "path/filepath" "strconv" "strings" "syscall" ) const ( Alias = "notevi_log" LogFile = "notevi-log.jsonl" Description = "private: notevi log" lockFile = "notevi.lock" ) // naming is the set of repo-visible names one generation of the tool gave the // sidecar: the revset alias, the file inside the change, and the change's own // description. type naming struct{ alias, logFile, description string } var current = naming{Alias, LogFile, Description} // legacy is what the tool called itself before the notevi rebrand. Every read // path still recognizes it so an un-migrated repository opens; Migrate renames // it in place so nothing in the repository keeps the old name. var legacy = naming{"vr_log", "vr-log.jsonl", "private: vr log"} const legacyLockFile = "vr-agent-logger.lock" var ErrNoLog = errors.New("notevi log has not been initialized") // Store is a repository's notevi log. Open is read-only with respect to jj; the // sidecar change and its config are created lazily by Append. type Store struct { root string configDir string lockPath string } // Open finds the jj workspace containing dir. func Open(dir string) (*Store, error) { root, err := jjOutput(dir, "workspace", "root") if err != nil { return nil, err } root = strings.TrimSpace(root) configPath, err := jjOutput(root, "config", "path", "--repo") if err != nil { return nil, err } configDir := filepath.Dir(strings.TrimSpace(configPath)) return &Store{ root: root, configDir: configDir, lockPath: filepath.Join(configDir, lockFile), }, nil } // Descriptions lists every change subject that has ever named a notevi sidecar, // current first. Callers that discover sidecars by description need the older // ones too, so an un-migrated repository is still recognized as having a log. func Descriptions() []string { return []string{current.description, legacy.description} } func (s *Store) Root() string { return s.root } func (s *Store) String() string { return fmt.Sprintf("jj sidecar %s:%s", Alias, LogFile) } // Read returns the current log. It reads a sidecar that still carries the // pre-rebrand names without migrating it, and before the first sidecar write it // falls back to a repo-root legacy log, which makes migration transparent. func (s *Store) Read() ([]byte, error) { var data []byte err := s.withLock(syscall.LOCK_SH, func() error { rev, n, found, _, err := s.locate() if err != nil { return err } if found { data, err = s.readRevision(rev, n.logFile) return err } data, err = os.ReadFile(filepath.Join(s.root, legacy.logFile)) if errors.Is(err, os.ErrNotExist) { return ErrNoLog } return err }) return data, err } // Generation is a cheap identity for the current contents, used by the web app // to notice appends. A sidecar rewrite gets a new commit id. func (s *Store) Generation() (string, error) { var generation string err := s.withLock(syscall.LOCK_SH, func() error { rev, _, found, _, err := s.locate() if err != nil { return err } if found { generation, err = s.logValue(rev, "commit_id") return err } info, err := os.Stat(filepath.Join(s.root, legacy.logFile)) if errors.Is(err, os.ErrNotExist) { generation = "empty" return nil } if err != nil { return err } generation = fmt.Sprintf("legacy:%d:%d", info.Size(), info.ModTime().UnixNano()) return nil }) return generation, err } // Append serializes sidecar rewrites across notevi processes. makePayload runs // while holding that lock and receives the complete current log, allowing note // numbering and reply validation to be atomic with the append. func (s *Store) Append(makePayload func(current []byte) ([]byte, error)) error { return s.withLock(syscall.LOCK_EX, func() error { rev, err := s.ensure() if err != nil { return err } sidecarData, err := s.readRevision(rev, LogFile) if err != nil { return err } current := sidecarData if len(current) == 0 { old, oldErr := os.ReadFile(filepath.Join(s.root, legacy.logFile)) if oldErr == nil { current = old } else if !errors.Is(oldErr, os.ErrNotExist) { return oldErr } } payload, err := makePayload(current) if err != nil { return err } if len(payload) == 0 { return errors.New("refusing to append an empty notevi log payload") } var appendData []byte if len(sidecarData) == 0 && len(current) > 0 { appendData = append(appendData, current...) } if len(current) > 0 && current[len(current)-1] != '\n' { appendData = append(appendData, '\n') } appendData = append(appendData, payload...) if appendData[len(appendData)-1] != '\n' { appendData = append(appendData, '\n') } return s.runAppend(rev, appendData) }) } // Migrate renames a pre-rebrand sidecar in place: the file inside the change, // the change description, the repo-local revset alias, and the alias mentioned // in git.private-commits. It reports whether anything was renamed and is a // no-op on a repository that is already current or has no sidecar at all. func (s *Store) Migrate() (migrated bool, err error) { err = s.withLock(syscall.LOCK_EX, func() error { rev, n, found, configured, err := s.locate() if err != nil { return err } if !found || n != legacy { // Still adopt an unconfigured current sidecar, so that a repo whose // jj config was lost (moving a workspace orphans it) is repaired. if found && !configured { return s.configure(rev) } return nil } if err := s.rename(rev); err != nil { return err } migrated = true return nil }) return migrated, err } // rename performs the legacy -> current renaming for an already-located sidecar. // The move is two runs, not one `mv`: see ensureLogFile. func (s *Store) rename(rev string) error { listed, err := s.jj("file", "list", "-r", rev, "--", legacy.logFile) if err != nil { return err } if strings.TrimSpace(listed) != "" { if err := s.ensureLogFile(rev); err != nil { return err } if _, err := s.jjRun(rev, "cat "+legacy.logFile+" >> "+LogFile+" && rm "+legacy.logFile); err != nil { return err } } if _, err := s.jj("describe", "-r", rev, "-m", Description); err != nil { return err } if err := s.configure(rev); err != nil { return err } if _, err := s.jj("config", "get", "revset-aliases."+legacy.alias); err == nil { if _, err := s.jj("config", "unset", "--repo", "revset-aliases."+legacy.alias); err != nil { return err } } os.Remove(filepath.Join(s.configDir, legacyLockFile)) //nolint:errcheck return nil } // configure points the repo-local alias at rev and refreshes the push guard. func (s *Store) configure(rev string) error { if _, err := s.jj("config", "set", "--repo", "revset-aliases."+Alias, strconv.Quote(rev)); err != nil { return err } return s.guard() } // guard keeps git.private-commits naming the current alias, rewriting a legacy // mention rather than leaving both names in the expression. func (s *Store) guard() error { private, err := s.jj("config", "get", "git.private-commits") if err != nil { return err } expr := strings.TrimSpace(private) next := expr if containsRevsetName(next, legacy.alias) { next = replaceRevsetName(next, legacy.alias, Alias) } if !containsRevsetName(next, Alias) { next = fmt.Sprintf("(%s) | %s", next, Alias) } if next == expr { return nil } _, err = s.jj("config", "set", "--repo", "git.private-commits", strconv.Quote(next)) return err } func (s *Store) withLock(kind int, fn func() error) error { if err := os.MkdirAll(filepath.Dir(s.lockPath), 0o755); err != nil { return err } f, err := os.OpenFile(s.lockPath, os.O_CREATE|os.O_RDWR, 0o600) if err != nil { return err } defer f.Close() if err := syscall.Flock(int(f.Fd()), kind); err != nil { return err } defer syscall.Flock(int(f.Fd()), syscall.LOCK_UN) //nolint:errcheck return fn() } func (s *Store) ensure() (string, error) { rev, n, found, configured, err := s.locate() if err != nil { return "", err } if found && n == legacy { // The first append to a pre-rebrand repository renames it, so no // repository keeps writing under the old name once notevi touches it. return rev, s.rename(rev) } if !found { if _, err := s.jj("new", "--no-edit", "root()", "-m", Description); err != nil { return "", err } ids, err := s.descriptionMatches(Description) if err != nil { return "", err } if len(ids) != 1 { return "", fmt.Errorf("created %q sidecar but found %d matching root children", Description, len(ids)) } rev, configured = ids[0], false } if !configured { return rev, s.configure(rev) } return rev, s.guard() } // locate finds this repository's sidecar under the current names, falling back // to the pre-rebrand ones, and reports which generation named it. func (s *Store) locate() (rev string, n naming, found, configured bool, err error) { rev, found, configured, err = s.resolveExisting(current) if err != nil || found { return rev, current, found, configured, err } rev, found, configured, err = s.resolveExisting(legacy) if !found { return rev, current, false, false, err } return rev, legacy, found, configured, err } func (s *Store) resolveExisting(n naming) (rev string, found, configured bool, err error) { configuredValue, configErr := s.jj("config", "get", "revset-aliases."+n.alias) if configErr == nil { rev, err = s.logValue("exactly("+n.alias+", 1)", "change_id") if err != nil { return "", false, true, fmt.Errorf("repo-local revset alias %s is invalid: %w", n.alias, err) } valid := fmt.Sprintf("exactly(%s & root()+ & subject(exact:%s), 1)", n.alias, strconv.Quote(n.description)) if _, err := s.logValue(valid, "change_id"); err != nil { return "", false, true, fmt.Errorf("repo-local revset alias %s=%q does not name notevi's sidecar: %w", n.alias, strings.TrimSpace(configuredValue), err) } return rev, true, true, nil } ids, err := s.descriptionMatches(n.description) if err != nil { return "", false, false, err } switch len(ids) { case 0: return "", false, false, nil case 1: return ids[0], true, false, nil default: return "", false, false, fmt.Errorf("found %d root children described %q; cannot choose a notevi sidecar", len(ids), n.description) } } func (s *Store) descriptionMatches(description string) ([]string, error) { revset := fmt.Sprintf("root()+ & subject(exact:%s)", strconv.Quote(description)) out, err := s.jj("log", "-G", "-r", revset, "-T", "change_id ++ \"\\n\"") if err != nil { return nil, err } var ids []string for _, id := range strings.Fields(out) { ids = append(ids, id) } return ids, nil } func (s *Store) logValue(revset, template string) (string, error) { out, err := s.jj("log", "-G", "-r", revset, "-T", template) if err != nil { return "", err } value := strings.TrimSpace(out) if value == "" { return "", fmt.Errorf("revision %q produced no %s", revset, template) } return value, nil } func (s *Store) readRevision(rev, logFile string) ([]byte, error) { listed, err := s.jj("file", "list", "-r", rev, "--", logFile) if err != nil { return nil, err } if strings.TrimSpace(listed) == "" { return nil, nil } out, err := s.jj("file", "show", "-r", rev, "--", logFile) return []byte(out), err } // ensureLogFile creates the log file, empty, if the sidecar does not have it. // // This is load-bearing, not tidiness. jj only auto-tracks a *new* file in the // run working copy while it is under snapshot.max-new-file-size, and the run // working copy does not take that limit from the command line, so writing a // whole log under a name the change has never held is silently dropped — the // commit is rewritten with the file missing and jj reports success. Writing the // file empty first is always under the limit; from then on every byte is a // modification of a tracked file, which snapshots at any size. func (s *Store) ensureLogFile(rev string) error { listed, err := s.jj("file", "list", "-r", rev, "--", LogFile) if err != nil { return err } if strings.TrimSpace(listed) != "" { return nil } _, err = s.jjRun(rev, ": > "+LogFile) return err } func (s *Store) runAppend(rev string, data []byte) error { if err := s.ensureLogFile(rev); err != nil { return err } f, err := os.CreateTemp(s.configDir, "notevi-log-append-*") if err != nil { return err } tmp := f.Name() defer os.Remove(tmp) //nolint:errcheck if _, err := f.Write(data); err != nil { f.Close() return err } if err := f.Close(); err != nil { return err } _, err = s.jjRun(rev, "cat \"$1\" >> "+LogFile, tmp) return err } // jjRun rewrites the sidecar change by running script against a clean checkout // of it. rev is a change id or alias; the working copy is never involved. func (s *Store) jjRun(rev, script string, scriptArgs ...string) (string, error) { args := []string{"--config", "snapshot.auto-track=\"all()\"", "--config", "snapshot.max-new-file-size=\"1GiB\"", "run", "--clean", "--root", "-r", "exactly((" + rev + "), 1)", "--", "sh", "-c", script, "sh"} return s.jj(append(args, scriptArgs...)...) } func (s *Store) jj(args ...string) (string, error) { return jjOutput(s.root, args...) } func jjOutput(dir string, args ...string) (string, error) { full := append([]string{"--ignore-working-copy", "--no-pager"}, args...) cmd := exec.Command("jj", full...) cmd.Dir = dir out, err := cmd.Output() if err != nil { message := strings.TrimSpace(string(out)) if ee, ok := err.(*exec.ExitError); ok { message = strings.TrimSpace(string(ee.Stderr)) } if message != "" { return "", fmt.Errorf("jj %s: %w: %s", strings.Join(args, " "), err, message) } return "", fmt.Errorf("jj %s: %w", strings.Join(args, " "), err) } return string(out), nil } func containsRevsetName(expr, name string) bool { for i := 0; i+len(name) <= len(expr); i++ { if expr[i:i+len(name)] != name { continue } before := i == 0 || !isNameByte(expr[i-1]) after := i+len(name) == len(expr) || !isNameByte(expr[i+len(name)]) if before && after { return true } } return false } // replaceRevsetName swaps whole revset names, so that rewriting an expression // mentioning vr_log cannot corrupt an unrelated name that merely contains it. func replaceRevsetName(expr, name, replacement string) string { var b strings.Builder for i := 0; i < len(expr); { if expr[i:min(i+len(name), len(expr))] == name { before := i == 0 || !isNameByte(expr[i-1]) after := i+len(name) == len(expr) || !isNameByte(expr[i+len(name)]) if before && after { b.WriteString(replacement) i += len(name) continue } } b.WriteByte(expr[i]) i++ } return b.String() } func isNameByte(b byte) bool { return b == '_' || b == '-' || b >= 'a' && b <= 'z' || b >= 'A' && b <= 'Z' || b >= '0' && b <= '9' }