#!/usr/bin/env nu
# sync-org.nu โ reconcile GitHub org state with local filesystem and declarations
# dry-run by default; --apply executes non-destructive transitions
# see SPEC.md for the full state machine
use std/log
# ---------- entry point ----------
def main [
--apply # apply non-destructive transitions
--apply-renames # apply rename plans (not yet implemented)
--json # machine-readable plan output
--only: string # scope to one repo
] {
if $apply_renames {
print "--apply-renames not yet implemented; ignoring"
}
let ws_root = (workspace-root)
let ws = (load-workspace $ws_root)
let lock = (load-lock $ws_root $ws.sync.lock_file)
let decls = (load-declarations $ws_root)
let org_state = (fetch-org-state $ws.org $json)
let fs_state = (scan-filesystem ($ws.root_dir | path expand))
let plan = (compute-plan $ws $lock $decls $org_state $fs_state $only)
if $json and not $apply {
print ($plan | to json)
exit (plan-exit-code $plan)
}
print-plan $plan $ws
let shadow = ($plan.transitions | where event == "shadow-conflict" | length)
if $shadow > 0 {
print "halt: shadow conflicts detected; resolve before --apply"
exit 10
}
if not $apply {
let muts = (actionable-transitions $plan.transitions)
if ($muts | length) == 0 {
exit 0
} else {
print "dry-run complete. re-run with --apply to execute"
exit 1
}
}
print "applying..."
let new_lock = (apply-plan $plan $ws $org_state $lock $ws_root)
write-lock $"($ws_root)/($ws.sync.lock_file)" $new_lock
print "applied. lock file updated."
exit 2
}
def actionable-transitions [transitions] {
$transitions | where event not-in ["noop" "fetch" "workspace-self" "archived-skip"]
}
# ---------- workspace loading ----------
def workspace-root [] {
# script lives at <root>/scripts/sync-org.nu
# if run from .github/, pwd works; otherwise traverse up from script path
let cwd = (pwd)
if ($"($cwd)/workspace.toml" | path exists) {
$cwd
} else {
error make {msg: "workspace.toml not found; run from .github/ root"}
}
}
def load-workspace [root: string] {
open $"($root)/workspace.toml"
}
def load-lock [root: string, lock_rel: string] {
let path = $"($root)/($lock_rel)"
if ($path | path exists) {
open $path
} else {
{schema: 1, repos: {}, orphans: {}}
}
}
def load-declarations [root: string] {
let dir = $"($root)/subgraphs"
if not ($dir | path exists) { return [] }
let files = (glob $"($dir)/*.md")
if ($files | length) == 0 { return [] }
$files | each {|f|
let parsed = (parse-declaration $f)
$parsed | merge {_path: $f}
} | where ($it.name? | is-not-empty)
}
def parse-declaration [path: string] {
let raw = (open --raw $path)
let split = (split-frontmatter $raw)
if $split.frontmatter == null { return {} }
try { $split.frontmatter | from yaml } catch { {} }
}
# ---------- github org state ----------
def fetch-org-state [org: string, quiet: bool] {
if not $quiet { log info $"fetching org state for ($org)..." }
let raw = (
^gh api $"/orgs/($org)/repos" --paginate
--jq '.[] | {name, visibility, archived, default_branch, ssh_url, clone_url}'
)
$raw
| lines
| where ($it | str length) > 0
| each { from json }
| reduce --fold {} {|it, acc| $acc | upsert $it.name $it }
}
# ---------- filesystem state ----------
def scan-filesystem [root: string] {
if not ($root | path exists) {
return {}
}
ls --all $root
| where type == dir
| where ($it.name | path basename) not-in [".", ".."]
| reduce --fold {} {|it, acc|
let name = ($it.name | path basename)
let classification = (classify-folder $it.name)
$acc | upsert $name $classification
}
}
def classify-folder [path: string] {
let has_git = ($"($path)/.git" | path exists)
if not $has_git {
return {kind: "plain", path: $path, remote: null}
}
let remote = (try {
^git -C $path remote get-url origin | str trim
} catch { null })
{kind: "git", path: $path, remote: $remote}
}
# ---------- plan computation ----------
def compute-plan [ws, lock, decls, org_state, fs_state, only] {
let org_names = ($org_state | columns)
let policy = $ws.subgraphs.policy
let relevant_org_names = if $only == null or ($only | is-empty) {
$org_names
} else {
$org_names | where $it == $only
}
# for each org repo: figure out what to do
let org_transitions = ($relevant_org_names | each {|name|
let org_repo = ($org_state | get $name)
let fs_entry = (try { $fs_state | get $name } catch { null })
let decl = ($decls | where name == $name | first)
let lock_entry = (try { $lock | get $"repos.($name)" } catch { null })
classify-transition $name $org_repo $fs_entry $decl $lock_entry $policy
})
# for each local folder not in org: flag as foreign/orphan/local-only
let fs_names = ($fs_state | columns)
let fs_only = ($fs_names | where $it not-in $org_names | each {|name|
let fs_entry = ($fs_state | get $name)
classify-fs-only $name $fs_entry
})
{
org: $ws.org
root_dir: ($ws.root_dir | path expand)
transitions: $org_transitions
fs_orphans: $fs_only
summary: (summarize $org_transitions $fs_only)
}
}
def classify-transition [name, org_repo, fs_entry, decl, lock_entry, policy] {
# archived handling
if $org_repo.archived {
if $fs_entry == null {
return {repo: $name, event: "archived-skip", detail: "archived upstream; not cloning"}
}
if $decl == null or ($decl.archived? | default false) != true {
return {repo: $name, event: "archive", detail: "mark declaration archived: true"}
}
return {repo: $name, event: "noop", detail: "archived, already marked"}
}
# not cloned
if $fs_entry == null {
if $decl == null {
let vis = $org_repo.visibility
let detail = $"clone and create declaration, visibility ($vis)"
return {repo: $name, event: "add", detail: $detail}
}
return {repo: $name, event: "add-clone", detail: "declaration exists; clone missing"}
}
# folder exists
if $fs_entry.kind == "plain" {
let p = $fs_entry.path
let detail = $"plain folder ($p) shadows org repo name"
return {repo: $name, event: "shadow-conflict", detail: $detail}
}
let expected_remote_ssh = $"git@github.com:cyberia-to/($name).git"
let expected_remote_https = $"https://github.com/cyberia-to/($name).git"
let remote_match = (
$fs_entry.remote == $expected_remote_ssh or
$fs_entry.remote == $expected_remote_https
)
if not $remote_match {
let r = $fs_entry.remote
let detail = $"remote ($r), expected ($expected_remote_ssh)"
return {repo: $name, event: "foreign-or-rename", detail: $detail}
}
# tracked repo; check flag reconciliation
let decl_vis = if $decl == null { null } else { $decl.visibility? }
let vis_drift = ($decl != null and $decl_vis != $org_repo.visibility)
let decl_arch = if $decl == null { false } else { ($decl.archived? | default false) }
let arch_drift = ($decl_arch != $org_repo.archived)
if $decl == null {
return {repo: $name, event: "adopt", detail: "clone present; create declaration stub"}
}
if $vis_drift {
let new_vis = $org_repo.visibility
let detail = $"declaration ($decl_vis), org ($new_vis)"
return {repo: $name, event: "visibility-flip", detail: $detail}
}
if $arch_drift {
return {repo: $name, event: "archive-drift", detail: "declaration archive flag mismatch"}
}
{repo: $name, event: "fetch", detail: "tracked; would git fetch"}
}
def classify-fs-only [name, fs_entry] {
if $fs_entry.kind == "plain" {
return {repo: $name, kind: "local-folder", detail: "plain directory, no org counterpart"}
}
if $fs_entry.remote == null {
return {repo: $name, kind: "no-remote", detail: "git repo with no remote"}
}
let is_org = ($fs_entry.remote | str contains "cyberia-to/")
if $is_org {
let r = $fs_entry.remote
let detail = $"cyberia-to remote but not in org list ($r)"
{repo: $name, kind: "orphan-or-rename", detail: $detail}
} else {
let r = $fs_entry.remote
let detail = $"remote points elsewhere ($r)"
{repo: $name, kind: "foreign", detail: $detail}
}
}
def summarize [transitions, fs_only] {
let by_event = ($transitions | group-by event | transpose event items | each {|r| {event: $r.event, count: ($r.items | length)}})
let fs_by_kind = ($fs_only | group-by kind | transpose kind items | each {|r| {kind: $r.kind, count: ($r.items | length)}})
{events: $by_event, fs_orphans: $fs_by_kind}
}
# ---------- output ----------
def print-plan [plan, ws] {
print $"workspace: ($plan.org) @ ($plan.root_dir)"
print ""
print "org transitions:"
print ($plan.transitions | sort-by event | select repo event detail | table --expand)
print ""
if ($plan.fs_orphans | length) > 0 {
print "filesystem entries not in org:"
print ($plan.fs_orphans | select repo kind detail | table --expand)
print ""
}
print "summary:"
print ($plan.summary.events | table)
if ($plan.summary.fs_orphans | length) > 0 {
print ($plan.summary.fs_orphans | table)
}
print ""
let muts = ($plan.transitions | where event not-in ["noop" "fetch" "workspace-self" "archived-skip"])
if ($muts | length) == 0 {
print "plan: no transitions needed (clean)"
} else {
print $"plan: ($muts | length) transitions pending review"
}
}
def plan-exit-code [plan] {
let shadow = ($plan.transitions | where event == "shadow-conflict" | length)
if $shadow > 0 { return 10 }
let muts = ($plan.transitions | where event not-in ["noop" "fetch" "workspace-self" "archived-skip"])
if ($muts | length) == 0 { 0 } else { 1 }
}
# ---------- apply ----------
def apply-plan [plan, ws, org_state, lock, ws_root: string] {
let root_dir = ($ws.root_dir | path expand)
let subgraphs_dir = $"($ws_root)/subgraphs"
mut repo_locks = ($lock.repos? | default {})
for t in $plan.transitions {
match $t.event {
"add" => {
let name = $t.repo
let org_repo = ($org_state | get $name)
apply-clone $name $org_repo $root_dir
apply-write-stub $name $org_repo $subgraphs_dir
$repo_locks = ($repo_locks | upsert $name (lock-entry $org_repo))
}
"add-clone" => {
let name = $t.repo
let org_repo = ($org_state | get $name)
apply-clone $name $org_repo $root_dir
apply-reconcile-decl $name $org_repo $subgraphs_dir
$repo_locks = ($repo_locks | upsert $name (lock-entry $org_repo))
}
"adopt" => {
let name = $t.repo
let org_repo = ($org_state | get $name)
apply-write-stub $name $org_repo $subgraphs_dir
$repo_locks = ($repo_locks | upsert $name (lock-entry $org_repo))
}
"fetch" => {
let name = $t.repo
let org_repo = ($org_state | get $name)
apply-fetch $name $root_dir
$repo_locks = ($repo_locks | upsert $name (lock-entry $org_repo))
}
"archive" | "archive-drift" => {
let name = $t.repo
let org_repo = ($org_state | get $name)
apply-reconcile-decl $name $org_repo $subgraphs_dir
$repo_locks = ($repo_locks | upsert $name (lock-entry $org_repo))
}
"visibility-flip" => {
let name = $t.repo
let org_repo = ($org_state | get $name)
apply-reconcile-decl $name $org_repo $subgraphs_dir
$repo_locks = ($repo_locks | upsert $name (lock-entry $org_repo))
}
"archived-skip" | "workspace-self" | "noop" => {
print $"skip: ($t.repo) (($t.event))"
}
"shadow-conflict" => {
print $"halt: shadow conflict on ($t.repo); should have exited earlier"
}
"foreign-or-rename" => {
print $"skip: ($t.repo) (foreign or rename, needs --apply-renames)"
}
_ => {
print $"skip: ($t.repo) unknown event ($t.event)"
}
}
}
{
schema: 1
org: $ws.org
synced_at: (date now | format date "%Y-%m-%dT%H:%M:%SZ")
repos: $repo_locks
}
}
def apply-clone [name: string, org_repo, root_dir: string] {
let dest = $"($root_dir)/($name)"
if ($dest | path exists) {
print $" already present: ($name)"
return
}
let url = $org_repo.clone_url
print $" clone ($name) โ ($dest)"
^git clone --quiet $url $dest
}
def apply-fetch [name: string, root_dir: string] {
let path = $"($root_dir)/($name)"
if not ($path | path exists) {
print $" skip fetch: ($name) not cloned"
return
}
print $" fetch ($name)"
do { ^git -C $path fetch --all --quiet --prune } | complete | ignore
}
def apply-write-stub [name: string, org_repo, subgraphs_dir: string] {
let path = $"($subgraphs_dir)/($name).md"
if ($path | path exists) {
apply-reconcile-decl $name $org_repo $subgraphs_dir
return
}
print $" write declaration: subgraphs/($name).md"
let vis = $org_repo.visibility
let arch = ($org_repo.archived | into string)
let content = [
"---"
$"name: ($name)"
$"repo: ($name)"
$"visibility: ($vis)"
$"archived: ($arch)"
"---"
""
$"# ($name)"
""
"<!-- auto-generated stub. replace freely with human-written context. -->"
] | str join "\n"
$content | save --force $path
}
def apply-reconcile-decl [name: string, org_repo, subgraphs_dir: string] {
let path = $"($subgraphs_dir)/($name).md"
if not ($path | path exists) {
apply-write-stub $name $org_repo $subgraphs_dir
return
}
let raw = (open --raw $path)
let split = (split-frontmatter $raw)
if $split.frontmatter == null {
print $" skip: ($path) has no frontmatter"
return
}
mut fm = $split.frontmatter
$fm = (ensure-yaml-key $fm "visibility" $org_repo.visibility)
$fm = (ensure-yaml-key $fm "archived" ($org_repo.archived | into string))
let rebuilt = $"---\n($fm)\n---\n($split.body)"
if $rebuilt != $raw {
print $" reconcile declaration: subgraphs/($name).md"
$rebuilt | save --force $path
}
}
def split-frontmatter [raw: string] {
let lines = ($raw | lines)
if ($lines | length) < 2 or ($lines | get 0) != "---" {
return {frontmatter: null, body: $raw}
}
let end_idx = ($lines
| enumerate
| skip 1
| where item == "---"
| get index
| first)
if $end_idx == null { return {frontmatter: null, body: $raw} }
let fm_lines = ($lines | skip 1 | take ($end_idx - 1))
let body_lines = ($lines | skip ($end_idx + 1))
{
frontmatter: ($fm_lines | str join "\n")
body: ($body_lines | str join "\n")
}
}
def ensure-yaml-key [yaml_text: string, key: string, value: string] {
let lines = ($yaml_text | lines)
let has_key = ($lines | any {|l|
let trimmed = ($l | str trim)
($trimmed | str starts-with $"($key):")
})
if $has_key {
$lines | each {|line|
let trimmed = ($line | str trim)
if ($trimmed | str starts-with $"($key):") {
$"($key): ($value)"
} else {
$line
}
} | str join "\n"
} else {
($lines ++ [$"($key): ($value)"]) | str join "\n"
}
}
def lock-entry [org_repo] {
{
visibility: $org_repo.visibility
archived: $org_repo.archived
default_branch: $org_repo.default_branch
last_seen: (date now | format date "%Y-%m-%dT%H:%M:%SZ")
}
}
def write-lock [path: string, data] {
$data | to toml | save --force $path
}