require "json" require "fileutils" module Backlog # The global project index at ~/.cc-os/projects.json: a derived map of # canonical repo path -> {name, tracker, remote, updated_at}, maintained as # a side effect of config-write (issue #27). Deliberately GLOBAL state # (distinct from per-project .cc-os/ under ADR-0027) so any session can ask # "which tracker does project X use" without visiting its repo. # # Pure over an injected path: callers hand in the already-canonical repo # path (the CLI resolves it via `git rev-parse --show-toplevel`). Read # paths never raise — a missing or corrupt file reads as an empty index; a # corrupt file is only overwritten when a new upsert happens. Writes are # atomic (temp file + rename in the same directory). class ProjectIndex VERSION = 1 # Default index path, honoring a CC_OS_HOME override (used by tests to # keep the real index untouched) and falling back to $HOME/.cc-os. def self.default_path(env: ENV) base = env["CC_OS_HOME"] || File.join(env.fetch("HOME", Dir.home), ".cc-os") File.join(base, "projects.json") end def initialize(path: self.class.default_path, today: -> { Time.now.strftime("%Y-%m-%d") }) @path = path @today = today end # @return [Hash] the projects map (canonical path -> row); {} when the # file is missing or unreadable/corrupt. Never raises. def all data = read data["projects"].is_a?(Hash) ? data["projects"] : {} end # Insert or update the row for a canonical repo path. Atomic write; # creates the parent directory if missing. def upsert(path:, name:, tracker:, remote:) projects = all projects[path] = { "name" => name, "tracker" => tracker, "remote" => remote, "updated_at" => @today.call } write({ "version" => VERSION, "projects" => projects }) projects[path] end private def read return {} unless File.exist?(@path) parsed = JSON.parse(File.read(@path)) parsed.is_a?(Hash) ? parsed : {} rescue StandardError {} end def write(data) dir = File.dirname(@path) FileUtils.mkdir_p(dir) temp = File.join(dir, ".projects.json.tmp.#{Process.pid}") File.write(temp, JSON.pretty_generate(data) + "\n") File.rename(temp, @path) end end end