From 57dfa2321f7fd03e91b3ac5cd13d0bf450a2d4f9 Mon Sep 17 00:00:00 2001 From: MasterEnderman Date: Wed, 12 Aug 2026 09:59:24 +0200 Subject: [PATCH] WIP repo management helper Signed-off-by: MasterEnderman --- home/dev/repositories.nix | 34 ++ pkgs/flake-repos/.gitignore | 1 + pkgs/flake-repos/Cargo.lock | 413 +++++++++++++++++++++++ pkgs/flake-repos/Cargo.toml | 16 + pkgs/flake-repos/default.nix | 17 + pkgs/flake-repos/src/cli.rs | 59 ++++ pkgs/flake-repos/src/commands/add.rs | 154 +++++++++ pkgs/flake-repos/src/commands/list.rs | 63 ++++ pkgs/flake-repos/src/commands/mod.rs | 41 +++ pkgs/flake-repos/src/commands/pull.rs | 51 +++ pkgs/flake-repos/src/commands/push.rs | 57 ++++ pkgs/flake-repos/src/commands/remove.rs | 44 +++ pkgs/flake-repos/src/commands/sync.rs | 28 ++ pkgs/flake-repos/src/main.rs | 27 ++ pkgs/flake-repos/src/model.rs | 79 +++++ pkgs/flake-repos/src/registry.rs | 101 ++++++ pkgs/flake-repos/src/render.rs | 427 ++++++++++++++++++++++++ pkgs/flake-repos/src/status.rs | 99 ++++++ pkgs/flake-repos/src/vcs.rs | 197 +++++++++++ 19 files changed, 1908 insertions(+) create mode 100644 home/dev/repositories.nix create mode 100644 pkgs/flake-repos/.gitignore create mode 100644 pkgs/flake-repos/Cargo.lock create mode 100644 pkgs/flake-repos/Cargo.toml create mode 100644 pkgs/flake-repos/default.nix create mode 100644 pkgs/flake-repos/src/cli.rs create mode 100644 pkgs/flake-repos/src/commands/add.rs create mode 100644 pkgs/flake-repos/src/commands/list.rs create mode 100644 pkgs/flake-repos/src/commands/mod.rs create mode 100644 pkgs/flake-repos/src/commands/pull.rs create mode 100644 pkgs/flake-repos/src/commands/push.rs create mode 100644 pkgs/flake-repos/src/commands/remove.rs create mode 100644 pkgs/flake-repos/src/commands/sync.rs create mode 100644 pkgs/flake-repos/src/main.rs create mode 100644 pkgs/flake-repos/src/model.rs create mode 100644 pkgs/flake-repos/src/registry.rs create mode 100644 pkgs/flake-repos/src/render.rs create mode 100644 pkgs/flake-repos/src/status.rs create mode 100644 pkgs/flake-repos/src/vcs.rs diff --git a/home/dev/repositories.nix b/home/dev/repositories.nix new file mode 100644 index 0000000..25b2a47 --- /dev/null +++ b/home/dev/repositories.nix @@ -0,0 +1,34 @@ +{ + config, + lib, + pkgs, + ... +}: { + options.modules.dev.flakeRepos = { + enable = lib.mkOption { + default = config.modules.dev.enable; + description = "Whether to enable the flake-repos registry tool."; + type = lib.types.bool; + }; + projectsRoot = lib.mkOption { + default = "${config.home.homeDirectory}/Projects"; + description = "Root directory under which all managed repos are cloned."; + type = lib.types.str; + }; + registryPath = lib.mkOption { + default = "${config.modules.dev.flakeManagement.repoPath}/home/dev/repos.json"; + description = "Path to the repos.json registry file."; + type = lib.types.str; + }; + }; + + config = lib.mkIf config.modules.dev.flakeRepos.enable { + home = { + packages = [pkgs.flake-repos]; + sessionVariables = { + FLAKE_REPOS_PROJECTS_ROOT = config.modules.dev.flakeRepos.projectsRoot; + FLAKE_REPOS_REGISTRY = config.modules.dev.flakeRepos.registryPath; + }; + }; + }; +} diff --git a/pkgs/flake-repos/.gitignore b/pkgs/flake-repos/.gitignore new file mode 100644 index 0000000..2f7896d --- /dev/null +++ b/pkgs/flake-repos/.gitignore @@ -0,0 +1 @@ +target/ diff --git a/pkgs/flake-repos/Cargo.lock b/pkgs/flake-repos/Cargo.lock new file mode 100644 index 0000000..283a9ae --- /dev/null +++ b/pkgs/flake-repos/Cargo.lock @@ -0,0 +1,413 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + +[[package]] +name = "anyhow" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" + +[[package]] +name = "bitflags" +version = "2.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "clap" +version = "4.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "473c7e07f409a8d772161724aa8db6a765a2532a70f9667eeb7b49d3d02fbdca" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b48fea5a88e9ae728a2dcbedbfc0e730f7d60da42e1cb049a83c9fb8b789889" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d012d2b9d65aca7f18f4d9878a045bc17899bba951561ba5ec3c2ba1eed9a061" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + +[[package]] +name = "console" +version = "0.16.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fe5f465a4f6fee88fad41b85d990f84c835335e85b5d9e6e63e0d06d28cba7c" +dependencies = [ + "encode_unicode", + "libc", + "unicode-width", + "windows-sys 0.61.2", +] + +[[package]] +name = "encode_unicode" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "fd-lock" +version = "4.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ce92ff622d6dadf7349484f42c93271a0d49b7cc4d466a936405bacbe10aa78" +dependencies = [ + "cfg-if", + "rustix", + "windows-sys 0.59.0", +] + +[[package]] +name = "flake-repos" +version = "0.1.0" +dependencies = [ + "anyhow", + "clap", + "console", + "fd-lock", + "serde", + "serde_json", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "syn" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-width" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/pkgs/flake-repos/Cargo.toml b/pkgs/flake-repos/Cargo.toml new file mode 100644 index 0000000..8ab219d --- /dev/null +++ b/pkgs/flake-repos/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "flake-repos" +version = "0.1.0" +edition = "2024" + +[dependencies] +anyhow = "1.0.104" +clap = { version = "4.6.6", features = ["derive"] } +console = "0.16.4" +fd-lock = "4.0.4" +serde = { version = "1.0.229", features = ["derive"] } +serde_json = "1.0.151" + +[profile.release] +lto = true +strip = true diff --git a/pkgs/flake-repos/default.nix b/pkgs/flake-repos/default.nix new file mode 100644 index 0000000..a526625 --- /dev/null +++ b/pkgs/flake-repos/default.nix @@ -0,0 +1,17 @@ +{ + lib, + rustPlatform, +}: +rustPlatform.buildRustPackage { + cargoLock = { + lockFile = ./Cargo.lock; + }; + pname = "flake-repos"; + src = ./.; + version = "0.1.0"; + meta = { + description = "Registry and reconciliation tool for jj/git-managed project repos"; + mainProgram = "flake-repos"; + platforms = lib.platforms.linux; + }; +} diff --git a/pkgs/flake-repos/src/cli.rs b/pkgs/flake-repos/src/cli.rs new file mode 100644 index 0000000..729f8d7 --- /dev/null +++ b/pkgs/flake-repos/src/cli.rs @@ -0,0 +1,59 @@ +use anyhow::Context; +use clap::Parser; +use std::path::PathBuf; + +const REGISTRY_ENV_VAR: &str = "FLAKE_REPOS_REGISTRY"; +const PROJECTS_ROOT_ENV_VAR: &str = "FLAKE_REPOS_PROJECTS_ROOT"; + +#[derive(Parser)] +#[command(name = "flake-repos")] +pub struct Cli { + /// Path to repos.json. Overrides FLAKE_REPOS_REGISTRY if set. + #[arg(long, global = true)] + pub registry: Option, + + /// Root directory repos are cloned under. Overrides + /// FLAKE_REPOS_PROJECTS_ROOT if set, defaults to $HOME/Projects. + #[arg(long, global = true)] + pub projects_root: Option, + + #[command(subcommand)] + pub command: Command, +} + +impl Cli { + pub fn resolve_registry_path(&self) -> anyhow::Result { + if let Some(p) = &self.registry { + return Ok(p.clone()); + } + std::env::var(REGISTRY_ENV_VAR) + .map(PathBuf::from) + .map_err(|_| { + anyhow::anyhow!( + "no registry path given: pass --registry or set {REGISTRY_ENV_VAR}" + ) + }) + } + + pub fn resolve_projects_root(&self) -> anyhow::Result { + if let Some(p) = &self.projects_root { + return Ok(p.clone()); + } + if let Ok(p) = std::env::var(PROJECTS_ROOT_ENV_VAR) { + return Ok(PathBuf::from(p)); + } + let home = + std::env::var("HOME").context("cannot default projects root: $HOME is not set")?; + Ok(PathBuf::from(home).join("Projects")) + } +} + +#[derive(clap::Subcommand)] +pub enum Command { + Add(crate::commands::add::AddArgs), + Remove(crate::commands::remove::RemoveArgs), + List(crate::commands::list::ListArgs), + Pull(crate::commands::pull::PullArgs), + Push(crate::commands::push::PushArgs), + Sync, +} diff --git a/pkgs/flake-repos/src/commands/add.rs b/pkgs/flake-repos/src/commands/add.rs new file mode 100644 index 0000000..27aaf7b --- /dev/null +++ b/pkgs/flake-repos/src/commands/add.rs @@ -0,0 +1,154 @@ +// src/commands/add.rs +use crate::commands::provision_new_repo; +use crate::model::{truncate, Repo}; +use crate::registry::{validate_local, RegistryHandle}; +use crate::vcs; +use anyhow::{bail, Context, Result}; +use clap::Args; +use std::collections::BTreeMap; +use std::path::Path; + +#[derive(Args)] +pub struct AddArgs { + /// Path relative to the projects root, e.g. "nixos-cfg". + pub local: String, + + /// Remote as name=uri. Repeatable for mirrors. Omit entirely to use + /// mode A (register an already-existing local repo as-is). Include at + /// least "origin=" to use mode B (clone or verify against a URI). + #[arg(long = "remote", value_parser = parse_remote)] + pub remotes: Vec<(String, String)>, +} + +fn parse_remote(s: &str) -> Result<(String, String), String> { + match s.split_once('=') { + Some((name, uri)) if !name.is_empty() && !uri.is_empty() => { + Ok((name.to_string(), uri.to_string())) + } + _ => Err(format!("expected NAME=URI, got '{s}'")), + } +} + +pub fn run(handle: &RegistryHandle, projects_root: &Path, args: AddArgs) -> Result<()> { + validate_local(&args.local)?; + let path = projects_root.join(&args.local); + + let remotes = if args.remotes.is_empty() { + mode_a(&path)? + } else { + mode_b(&path, &args.remotes)? + }; + + let mut changed_summary: Option = None; + + handle.update(|registry| { + if let Some(existing) = registry.find_mut(&args.local) { + changed_summary = Some(diff_remotes(&existing.remotes, &remotes)); + existing.remotes = remotes.clone(); + } else { + registry.repos.push(Repo { + local: args.local.clone(), + remotes: remotes.clone(), + }); + } + Ok(()) + })?; + + println!("registered {}", truncate(&args.local, 60)); + if let Some(summary) = changed_summary { + if !summary.is_empty() { + println!("{summary}"); + } + } + Ok(()) +} + +/// Mode A: local path given, no --remote flags. Repo must already exist +/// with at least one remote configured; jj is initialized if missing. +fn mode_a(path: &Path) -> Result> { + if !path.exists() { + bail!( + "{} does not exist — pass --remote NAME=URI to clone it instead", + path.display() + ); + } + if !vcs::is_git_repo(path) { + bail!("{} is not a git repository", path.display()); + } + + let remotes = vcs::git_remotes(path) + .with_context(|| format!("failed to read remotes from {}", path.display()))?; + if remotes.is_empty() { + bail!("{} has no git remotes configured", path.display()); + } + + if !vcs::is_jj_initialized(path) { + vcs::jj_init_colocate(path)?; + println!("initialized jj in {}", path.display()); + } + + if let (Some(bookmark), true) = (vcs::primary_bookmark(path)?, remotes.contains_key("origin")) { + vcs::jj_track_bookmark(path, &bookmark, "origin")?; + } + + Ok(remotes) +} + +/// Mode B: local path + at least one --remote given, must include "origin". +/// If the path exists, its actual remotes must match what was passed in. +/// If it doesn't exist, it's cloned from origin and mirrors are attached. +fn mode_b(path: &Path, given: &[(String, String)]) -> Result> { + let mut remotes = BTreeMap::new(); + for (name, uri) in given { + if remotes.insert(name.clone(), uri.clone()).is_some() { + bail!("remote '{name}' given more than once"); + } + } + if !remotes.contains_key("origin") { + bail!("mode B requires an 'origin' remote, e.g. --remote origin=ssh://..."); + } + + if path.exists() { + if !vcs::is_git_repo(path) { + bail!("{} exists but is not a git repository", path.display()); + } + let actual = vcs::git_remotes(path)?; + if actual != remotes { + bail!( + "{} exists with different remotes than provided:\n existing: {:?}\n provided: {:?}", + path.display(), + actual, + remotes + ); + } + if !vcs::is_jj_initialized(path) { + vcs::jj_init_colocate(path)?; + println!("initialized jj in {}", path.display()); + } + } else { + provision_new_repo(path, &remotes) + .with_context(|| format!("failed to provision {}", path.display()))?; + println!("cloned into {}", path.display()); + } + + Ok(remotes) +} + +fn diff_remotes(old: &BTreeMap, new: &BTreeMap) -> String { + let mut lines = Vec::new(); + for (name, uri) in new { + match old.get(name) { + None => lines.push(format!(" + {name} = {uri}")), + Some(old_uri) if old_uri != uri => { + lines.push(format!(" ~ {name}: {old_uri} -> {uri}")) + } + _ => {} + } + } + for name in old.keys() { + if !new.contains_key(name) { + lines.push(format!(" - {name}")); + } + } + lines.join("\n") +} diff --git a/pkgs/flake-repos/src/commands/list.rs b/pkgs/flake-repos/src/commands/list.rs new file mode 100644 index 0000000..4af2d02 --- /dev/null +++ b/pkgs/flake-repos/src/commands/list.rs @@ -0,0 +1,63 @@ +use crate::registry::RegistryHandle; +use crate::render::render_table; +use crate::status::collect_all; +use anyhow::Result; +use clap::Args; +use console::{Style, Term}; +use std::path::Path; +use std::time::Duration; + +const SPINNER: [&str; 10] = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]; +const TICK: Duration = Duration::from_millis(100); + +#[derive(Args)] +pub struct ListArgs { + /// Keep running, refreshing the table on an interval. + #[arg(long)] + pub watch: bool, + + /// Refresh interval in seconds (only used with --watch). + #[arg(long, default_value = "60")] + pub interval: u64, +} + +pub fn run(handle: &RegistryHandle, projects_root: &Path, args: ListArgs) -> Result<()> { + let term = Term::stdout(); + + loop { + let registry = handle.read()?; + let entries = collect_all(projects_root, ®istry.repos)?; + + term.clear_screen()?; + println!("{}", render_table(&entries)); + + if !args.watch { + break; + } + + spin_for(&term, args.interval)?; + } + + Ok(()) +} + +fn spin_for(term: &Term, interval_secs: u64) -> Result<()> { + let accent = Style::new().cyan(); + let dim = Style::new().color256(8); + + let ticks_total = (interval_secs * 1000) / TICK.as_millis() as u64; + for i in 0..ticks_total.max(1) { + let frame = SPINNER[i as usize % SPINNER.len()]; + let elapsed_ms = i * TICK.as_millis() as u64; + let remaining = interval_secs.saturating_sub(elapsed_ms / 1000); + + term.write_str(&format!( + "\r{} {} ", + accent.apply_to(frame), + dim.apply_to(format!("refreshing in {remaining}s")) + ))?; + term.flush()?; + std::thread::sleep(TICK); + } + Ok(()) +} diff --git a/pkgs/flake-repos/src/commands/mod.rs b/pkgs/flake-repos/src/commands/mod.rs new file mode 100644 index 0000000..932ef4f --- /dev/null +++ b/pkgs/flake-repos/src/commands/mod.rs @@ -0,0 +1,41 @@ +// src/commands/mod.rs +pub mod add; +pub mod list; +pub mod pull; +pub mod push; +pub mod remove; +pub mod sync; + +use crate::vcs; +use anyhow::{Context, Result}; +use std::collections::BTreeMap; +use std::path::Path; + +/// Clones a repo fresh from its origin remote, attaches any additional +/// mirrors, and tracks the primary bookmark. +pub fn provision_new_repo(path: &Path, remotes: &BTreeMap) -> Result<()> { + let origin_uri = remotes + .get("origin") + .context("cannot provision repo: no 'origin' remote configured")?; + + vcs::jj_clone(origin_uri, path)?; + + for (name, uri) in remotes.iter().filter(|(n, _)| n.as_str() != "origin") { + vcs::git_remote_add(path, name, uri)?; + } + + if let Some(bookmark) = vcs::primary_bookmark(path)? { + vcs::jj_track_bookmark(path, &bookmark, "origin")?; + } + + Ok(()) +} + +/// Ensures jj is initialized in an already-present git repo. No-op if +/// already initialized. +pub fn ensure_jj_initialized(path: &Path) -> Result<()> { + if !vcs::is_jj_initialized(path) { + vcs::jj_init_colocate(path)?; + } + Ok(()) +} diff --git a/pkgs/flake-repos/src/commands/pull.rs b/pkgs/flake-repos/src/commands/pull.rs new file mode 100644 index 0000000..11272f7 --- /dev/null +++ b/pkgs/flake-repos/src/commands/pull.rs @@ -0,0 +1,51 @@ +use crate::registry::RegistryHandle; +use crate::vcs; +use anyhow::{bail, Result}; +use clap::Args; +use std::path::Path; + +#[derive(Args)] +pub struct PullArgs { + /// Repo to pull (local path or name). Omit when using --all. + pub repo: Option, + + /// Pull every registered repo. + #[arg(long)] + pub all: bool, +} + +pub fn run(handle: &RegistryHandle, projects_root: &Path, args: PullArgs) -> Result<()> { + if args.all == args.repo.is_some() { + bail!("pass either a repo name or --all, not both/neither"); + } + + let registry = handle.read()?; + let targets: Vec<_> = if args.all { + registry.repos.iter().collect() + } else { + let name = args.repo.as_deref().unwrap(); + registry + .repos + .iter() + .filter(|r| r.local == name || r.name() == name) + .collect() + }; + + if targets.is_empty() { + bail!("no matching registered repo"); + } + + for repo in targets { + let path = projects_root.join(&repo.local); + if !path.exists() { + println!("skip {}: not cloned (run sync first)", repo.name()); + continue; + } + match vcs::jj_fetch(&path, "origin") { + Ok(()) => println!("pulled {}", repo.name()), + Err(e) => println!("failed {}: {e}", repo.name()), + } + } + + Ok(()) +} diff --git a/pkgs/flake-repos/src/commands/push.rs b/pkgs/flake-repos/src/commands/push.rs new file mode 100644 index 0000000..986aa64 --- /dev/null +++ b/pkgs/flake-repos/src/commands/push.rs @@ -0,0 +1,57 @@ +use crate::registry::RegistryHandle; +use crate::vcs; +use anyhow::{bail, Result}; +use clap::Args; +use std::path::Path; + +#[derive(Args)] +pub struct PushArgs { + /// Repo to push (local path or name). Omit when using --all. + pub repo: Option, + + /// Push every registered repo. + #[arg(long)] + pub all: bool, + + /// Allow creating bookmarks on the remote that don't exist there yet. + #[arg(long)] + pub allow_new: bool, +} + +pub fn run(handle: &RegistryHandle, projects_root: &Path, args: PushArgs) -> Result<()> { + if args.all == args.repo.is_some() { + bail!("pass either a repo name or --all, not both/neither"); + } + + let registry = handle.read()?; + let targets: Vec<_> = if args.all { + registry.repos.iter().collect() + } else { + let name = args.repo.as_deref().unwrap(); + registry + .repos + .iter() + .filter(|r| r.local == name || r.name() == name) + .collect() + }; + + if targets.is_empty() { + bail!("no matching registered repo"); + } + + for repo in targets { + let path = projects_root.join(&repo.local); + if !path.exists() { + println!("skip {}: not cloned", repo.name()); + continue; + } + for remote_name in repo.remotes.keys() { + match vcs::jj_push(&path, remote_name, args.allow_new) { + Ok(()) => println!("pushed {} -> {remote_name}", repo.name()), + Err(e) => println!("failed {} -> {remote_name}: {e}", repo.name()), + } + } + } + + Ok(()) +} diff --git a/pkgs/flake-repos/src/commands/remove.rs b/pkgs/flake-repos/src/commands/remove.rs new file mode 100644 index 0000000..5d7314d --- /dev/null +++ b/pkgs/flake-repos/src/commands/remove.rs @@ -0,0 +1,44 @@ +use crate::registry::RegistryHandle; +use anyhow::{bail, Result}; +use clap::Args; + +#[derive(Args)] +pub struct RemoveArgs { + /// Either the exact `local` path as registered, or just the repo name + /// (last path component) if unambiguous. + pub repo: String, +} + +pub fn run(handle: &RegistryHandle, args: RemoveArgs) -> Result<()> { + handle.update(|registry| { + if registry.remove(&args.repo) { + return Ok(()); + } + + let matches: Vec = registry + .repos + .iter() + .filter(|r| r.name() == args.repo) + .map(|r| r.local.clone()) + .collect(); + + match matches.as_slice() { + [] => bail!("no registered repo matches '{}'", args.repo), + [single] => { + registry.remove(single); + Ok(()) + } + multiple => bail!( + "'{}' is ambiguous, matches: {}", + args.repo, + multiple.join(", ") + ), + } + })?; + + println!( + "removed {} from the registry (local files untouched)", + args.repo + ); + Ok(()) +} diff --git a/pkgs/flake-repos/src/commands/sync.rs b/pkgs/flake-repos/src/commands/sync.rs new file mode 100644 index 0000000..778552c --- /dev/null +++ b/pkgs/flake-repos/src/commands/sync.rs @@ -0,0 +1,28 @@ +use crate::commands::{ensure_jj_initialized, provision_new_repo}; +use crate::registry::RegistryHandle; +use anyhow::Result; +use std::path::Path; + +/// Activation-script entrypoint: clones anything registered but missing, +/// and jj-initializes anything present but not yet a jj repo. +pub fn run(handle: &RegistryHandle, projects_root: &Path) -> Result<()> { + let registry = handle.read()?; + + for repo in ®istry.repos { + let path = projects_root.join(&repo.local); + + if !path.exists() { + match provision_new_repo(&path, &repo.remotes) { + Ok(()) => println!("provisioned {}", repo.name()), + Err(e) => println!("failed to provision {}: {e}", repo.name()), + } + continue; + } + + if let Err(e) = ensure_jj_initialized(&path) { + println!("failed to jj-init {}: {e}", repo.name()); + } + } + + Ok(()) +} diff --git a/pkgs/flake-repos/src/main.rs b/pkgs/flake-repos/src/main.rs new file mode 100644 index 0000000..f2552aa --- /dev/null +++ b/pkgs/flake-repos/src/main.rs @@ -0,0 +1,27 @@ +mod cli; +mod commands; +mod model; +mod registry; +mod render; +mod status; +mod vcs; + +use clap::Parser; +use cli::{Cli, Command}; +use registry::RegistryHandle; + +fn main() -> anyhow::Result<()> { + let cli = Cli::parse(); + let registry_path = cli.resolve_registry_path()?; + let projects_root = cli.resolve_projects_root()?; + let handle = RegistryHandle::new(registry_path); + + match cli.command { + Command::Add(args) => commands::add::run(&handle, &projects_root, args), + Command::Remove(args) => commands::remove::run(&handle, args), + Command::List(args) => commands::list::run(&handle, &projects_root, args), + Command::Pull(args) => commands::pull::run(&handle, &projects_root, args), + Command::Push(args) => commands::push::run(&handle, &projects_root, args), + Command::Sync => commands::sync::run(&handle, &projects_root), + } +} diff --git a/pkgs/flake-repos/src/model.rs b/pkgs/flake-repos/src/model.rs new file mode 100644 index 0000000..0cebb81 --- /dev/null +++ b/pkgs/flake-repos/src/model.rs @@ -0,0 +1,79 @@ +use serde::{Deserialize, Serialize}; +use std::collections::BTreeMap; + +#[derive(Debug, Serialize, Deserialize, Default)] +pub struct Registry { + pub repos: Vec, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct Repo { + /// Path relative to $HOME/Projects/ + pub local: String, + /// Remote name -> SSH URI. "origin" is the reserved primary remote + /// used for clone/fetch during sync; all other keys are push mirrors. + pub remotes: BTreeMap, +} + +impl Repo { + pub fn name(&self) -> &str { + self.local.rsplit('/').next().unwrap_or(&self.local) + } +} + +impl Registry { + pub fn find_mut(&mut self, local: &str) -> Option<&mut Repo> { + self.repos.iter_mut().find(|r| r.local == local) + } + + pub fn remove(&mut self, local: &str) -> bool { + let before = self.repos.len(); + self.repos.retain(|r| r.local != local); + self.repos.len() != before + } +} + +pub fn truncate(s: &str, max: usize) -> String { + let chars: Vec = s.chars().collect(); + if chars.len() <= max { + s.to_string() + } else { + let mut out: String = chars[..max.saturating_sub(1)].iter().collect(); + out.push('…'); + out + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn truncate_short_string_unchanged() { + assert_eq!(truncate("nixos-cfg", 30), "nixos-cfg"); + } + + #[test] + fn truncate_exact_length_unchanged() { + let s = "a".repeat(30); + assert_eq!(truncate(&s, 30), s); + } + + #[test] + fn truncate_long_string_replaces_last_char() { + let s = "a".repeat(35); + let result = truncate(&s, 30); + assert_eq!(result.chars().count(), 30); + assert!(result.ends_with('…')); + assert_eq!(&result[..result.len() - '…'.len_utf8()], &"a".repeat(29)); + } + + #[test] + fn repo_name_from_nested_local_path() { + let repo = Repo { + local: "work/nixos-cfg".into(), + remotes: BTreeMap::new(), + }; + assert_eq!(repo.name(), "nixos-cfg"); + } +} diff --git a/pkgs/flake-repos/src/registry.rs b/pkgs/flake-repos/src/registry.rs new file mode 100644 index 0000000..0a5f209 --- /dev/null +++ b/pkgs/flake-repos/src/registry.rs @@ -0,0 +1,101 @@ +use crate::model::Registry; +use anyhow::{bail, Context, Result}; +use std::fs::{self, File}; +use std::io::Write; +use std::path::{Path, PathBuf}; + +pub struct RegistryHandle { + path: PathBuf, +} + +impl RegistryHandle { + pub fn new(path: impl Into) -> Self { + Self { path: path.into() } + } + + fn lock_path(&self) -> PathBuf { + let mut p = self.path.clone(); + let fname = p + .file_name() + .unwrap_or_default() + .to_string_lossy() + .to_string(); + p.set_file_name(format!("{fname}.lock")); + p + } + + pub fn read(&self) -> Result { + if !self.path.exists() { + return Ok(Registry::default()); + } + let raw = fs::read_to_string(&self.path) + .with_context(|| format!("failed to read {}", self.path.display()))?; + serde_json::from_str(&raw) + .with_context(|| format!("failed to parse {}", self.path.display())) + } + + pub fn update(&self, f: F) -> Result<()> + where + F: FnOnce(&mut Registry) -> Result<()>, + { + let lock_path = self.lock_path(); + let lock_file = File::create(&lock_path) + .with_context(|| format!("failed to create lock file {}", lock_path.display()))?; + let mut lock = fd_lock::RwLock::new(lock_file); + let _guard = lock + .write() + .context("failed to acquire lock on repos.json")?; + + let mut registry = self.read()?; + f(&mut registry)?; + self.write_atomic(®istry)?; + Ok(()) + } + + fn write_atomic(&self, registry: &Registry) -> Result<()> { + let dir = self + .path + .parent() + .context("repos.json path has no parent directory")?; + + let json = + serde_json::to_string_pretty(registry).context("failed to serialize registry")?; + + let tmp_path = dir.join(format!( + ".{}.tmp", + self.path.file_name().unwrap_or_default().to_string_lossy() + )); + + { + let mut tmp = File::create(&tmp_path) + .with_context(|| format!("failed to create tempfile {}", tmp_path.display()))?; + tmp.write_all(json.as_bytes())?; + tmp.write_all(b"\n")?; + tmp.sync_all() + .context("failed to fsync tempfile before rename")?; + } + + fs::rename(&tmp_path, &self.path).with_context(|| { + format!( + "failed to atomically replace {} with {}", + self.path.display(), + tmp_path.display() + ) + })?; + + Ok(()) + } +} + +pub fn validate_local(local: &str) -> Result<()> { + if local.is_empty() { + bail!("local path cannot be empty"); + } + if local.starts_with('/') || local.ends_with('/') { + bail!("local path must be relative, no leading or trailing slash: {local}"); + } + if Path::new(local).components().any(|c| c.as_os_str() == "..") { + bail!("local path must not contain '..': {local}"); + } + Ok(()) +} diff --git a/pkgs/flake-repos/src/render.rs b/pkgs/flake-repos/src/render.rs new file mode 100644 index 0000000..5de3cec --- /dev/null +++ b/pkgs/flake-repos/src/render.rs @@ -0,0 +1,427 @@ +use crate::model::{truncate, Repo}; +use crate::status::{LocalState, RemoteStatus, RepoStatus, SyncState}; +use console::{measure_text_width, Style}; + +const LOCAL_NAME_MAX: usize = 30; +const REMOTE_NAME_MAX: usize = 20; + +mod glyphs { + pub const CONN_ROOT: &str = "━━━>"; + pub const CONN_BRANCH: &str = "━┳━>"; + pub const CONN_MID: &str = "┣━>"; + pub const CONN_LAST: &str = "┗━>"; + pub const CONN_STRAIGHT: &str = "━━━>"; + + pub const COL_REPO: &str = "\u{f02a2}"; + pub const COL_LOCAL: &str = "\u{f0219}"; + pub const COL_SYNC: &str = "\u{f0450}"; + pub const COL_REMOTE: &str = "\u{f02a4}"; + + pub const ICON_CLEAN: &str = "\u{f0132}"; + pub const ICON_DIRTY: &str = "\u{f044a}"; + pub const ICON_MISSING: &str = "\u{f05de}"; + pub const ICON_IN_SYNC: &str = "\u{eab2}"; + pub const ICON_AHEAD: &str = "\u{f0575}"; + pub const ICON_BEHIND: &str = "\u{f0577}"; + pub const ICON_DIVERGED: &str = "\u{f0028}"; +} + +mod color { + use console::Style; + + pub fn frame() -> Style { + Style::new().color256(8) + } + pub fn content() -> Style { + Style::new().white() + } + pub fn connector() -> Style { + Style::new().cyan() + } + pub fn green() -> Style { + Style::new().green() + } + pub fn yellow() -> Style { + Style::new().yellow() + } + pub fn red() -> Style { + Style::new().red() + } +} + +struct Cell { + text: String, + style: Style, +} + +fn local_state_cell(state: &LocalState) -> Cell { + match state { + LocalState::Clean => Cell { + text: format!("{} clean", glyphs::ICON_CLEAN), + style: color::green(), + }, + LocalState::Dirty(n) => Cell { + text: format!("{} dirty ({n})", glyphs::ICON_DIRTY), + style: color::yellow(), + }, + } +} + +fn not_present_local_cell() -> Cell { + Cell { + text: format!("{} MISSING", glyphs::ICON_MISSING), + style: color::red(), + } +} + +fn sync_state_cell(state: &SyncState) -> Cell { + match state { + SyncState::InSync => Cell { + text: format!("{} in-sync", glyphs::ICON_IN_SYNC), + style: color::green(), + }, + SyncState::Ahead => Cell { + text: format!("{} ahead", glyphs::ICON_AHEAD), + style: color::yellow(), + }, + SyncState::Behind => Cell { + text: format!("{} behind", glyphs::ICON_BEHIND), + style: color::yellow(), + }, + SyncState::Diverged => Cell { + text: format!("{} diverged", glyphs::ICON_DIVERGED), + style: color::red(), + }, + SyncState::Missing => Cell { + text: format!("{} MISSING", glyphs::ICON_MISSING), + style: color::red(), + }, + } +} + +struct Row { + repo_col: Option, + connector_left: &'static str, + local_cell: Option, + connector_mid: &'static str, + sync_cell: Cell, + connector_right: &'static str, + remote_cell: Cell, +} + +pub fn render_table(entries: &[(Repo, RepoStatus)]) -> String { + let rows = build_rows(entries); + let widths = compute_widths(&rows); + draw(&rows, &widths) +} + +struct Widths { + repo: usize, + local: usize, + sync: usize, + remote: usize, +} + +fn build_rows(entries: &[(Repo, RepoStatus)]) -> Vec { + let mut rows = Vec::new(); + + for (repo, status) in entries { + let repo_name = truncate(repo.name(), LOCAL_NAME_MAX); + + match status { + RepoStatus::NotPresent => { + let remote_names: Vec<&String> = repo.remotes.keys().collect(); + if remote_names.is_empty() { + rows.push(Row { + repo_col: Some(repo_name), + connector_left: glyphs::CONN_ROOT, + local_cell: Some(not_present_local_cell()), + connector_mid: "", + sync_cell: Cell { + text: String::new(), + style: color::content(), + }, + connector_right: "", + remote_cell: Cell { + text: String::new(), + style: color::content(), + }, + }); + continue; + } + + push_repo_block( + &mut rows, + repo_name, + Some(not_present_local_cell()), + remote_names.into_iter().map(|name| { + ( + Cell { + text: format!("{} -", glyphs::ICON_MISSING), + style: color::red(), + }, + truncate(name, REMOTE_NAME_MAX), + ) + }), + ); + } + RepoStatus::Present { + local_state, + remotes, + } => { + push_repo_block( + &mut rows, + repo_name, + Some(local_state_cell(local_state)), + remotes.iter().map(|r: &RemoteStatus| { + (sync_state_cell(&r.sync), truncate(&r.name, REMOTE_NAME_MAX)) + }), + ); + } + } + } + + rows +} + +fn push_repo_block( + rows: &mut Vec, + repo_name: String, + local_cell: Option, + remotes: impl ExactSizeIterator, +) { + let n = remotes.len(); + for (i, (sync_cell, remote_name)) in remotes.enumerate() { + let is_first = i == 0; + let is_last = i == n - 1; + + let connector_left = if is_first { + if n == 1 { + glyphs::CONN_STRAIGHT + } else { + glyphs::CONN_BRANCH + } + } else { + "" + }; + + let connector_mid = if n == 1 { + "" + } else if is_last { + glyphs::CONN_LAST + } else { + glyphs::CONN_MID + }; + + rows.push(Row { + repo_col: if is_first { + Some(repo_name.clone()) + } else { + None + }, + connector_left, + local_cell: if is_first { local_cell.clone() } else { None }, + connector_mid, + sync_cell, + connector_right: glyphs::CONN_STRAIGHT, + remote_cell: Cell { + text: remote_name, + style: color::content(), + }, + }); + } +} + +impl Clone for Cell { + fn clone(&self) -> Self { + Cell { + text: self.text.clone(), + style: self.style.clone(), + } + } +} + +fn compute_widths(rows: &[Row]) -> Widths { + let header_repo = measure_text_width(&format!("{} repo", glyphs::COL_REPO)); + let header_local = measure_text_width(&format!("{} local", glyphs::COL_LOCAL)); + let header_sync = measure_text_width(&format!("{} sync", glyphs::COL_SYNC)); + let header_remote = measure_text_width(&format!("{} remote", glyphs::COL_REMOTE)); + + let mut w = Widths { + repo: header_repo, + local: header_local, + sync: header_sync, + remote: header_remote, + }; + + for row in rows { + if let Some(name) = &row.repo_col { + w.repo = w.repo.max(measure_text_width(name)); + } + if let Some(cell) = &row.local_cell { + w.local = w.local.max(measure_text_width(&cell.text)); + } + w.sync = w.sync.max(measure_text_width(&row.sync_cell.text)); + w.remote = w.remote.max(measure_text_width(&row.remote_cell.text)); + } + + w +} + +fn pad(s: &str, width: usize) -> String { + let visible = measure_text_width(s); + if visible >= width { + s.to_string() + } else { + format!("{s}{}", " ".repeat(width - visible)) + } +} + +fn draw(rows: &[Row], w: &Widths) -> String { + let frame = color::frame(); + + let header_plain = format_header_plain(w); + let row_plains: Vec = rows.iter().map(|r| format_row_plain(r, w)).collect(); + + let inner_width = std::iter::once(&header_plain) + .chain(row_plains.iter()) + .map(|s| measure_text_width(s)) + .max() + .unwrap_or(0); + + let mut out = String::new(); + + out.push_str( + &frame + .apply_to(format!("┌{}┐", "─".repeat(inner_width + 2))) + .to_string(), + ); + out.push('\n'); + + out.push_str(&frame.apply_to("│ ").to_string()); + out.push_str(&pad(&colorize_header(w), inner_width)); + out.push_str(&frame.apply_to(" │").to_string()); + out.push('\n'); + + out.push_str( + &frame + .apply_to(format!("├{}┤", "─".repeat(inner_width + 2))) + .to_string(), + ); + out.push('\n'); + + for (row, plain) in rows.iter().zip(row_plains.iter()) { + let styled = colorize_row(row, w); + let pad_amount = inner_width.saturating_sub(measure_text_width(plain)); + + out.push_str(&frame.apply_to("│ ").to_string()); + out.push_str(&styled); + out.push_str(&" ".repeat(pad_amount)); + out.push_str(&frame.apply_to(" │").to_string()); + out.push('\n'); + } + + out.push_str( + &frame + .apply_to(format!("└{}┘", "─".repeat(inner_width + 2))) + .to_string(), + ); + out +} + +fn format_header_plain(w: &Widths) -> String { + format!( + "{} {}{} {} {}{} {} {}{} {} {}", + pad(&format!("{} repo", glyphs::COL_REPO), w.repo), + pad("", 4), + pad(&format!("{} local", glyphs::COL_LOCAL), w.local), + pad("", 4), + pad(&format!("{} sync", glyphs::COL_SYNC), w.sync), + pad("", 4), + pad(&format!("{} remote", glyphs::COL_REMOTE), w.remote), + "", + "", + "", + "", + ) +} + +fn format_row_plain(row: &Row, w: &Widths) -> String { + format!( + "{} {} [{}] {} [{}] {} [{}]", + pad(row.repo_col.as_deref().unwrap_or(""), w.repo), + pad(row.connector_left, 4), + pad( + row.local_cell + .as_ref() + .map(|c| c.text.as_str()) + .unwrap_or(""), + w.local + ), + pad(row.connector_mid, 4), + pad(&row.sync_cell.text, w.sync), + pad(row.connector_right, 4), + pad(&row.remote_cell.text, w.remote), + ) +} + +fn colorize_header(w: &Widths) -> String { + let content = color::content(); + format!( + "{} {} {} {} {} {} {} {}", + content.apply_to(pad(&format!("{} repo", glyphs::COL_REPO), w.repo)), + " ".repeat(4), + content.apply_to(pad(&format!("{} local", glyphs::COL_LOCAL), w.local)), + " ".repeat(4), + content.apply_to(pad(&format!("{} sync", glyphs::COL_SYNC), w.sync)), + " ".repeat(4), + content.apply_to(pad(&format!("{} remote", glyphs::COL_REMOTE), w.remote)), + "", + ) +} + +fn colorize_row(row: &Row, w: &Widths) -> String { + let content = color::content(); + let conn = color::connector(); + + let repo_field = content.apply_to(pad(row.repo_col.as_deref().unwrap_or(""), w.repo)); + + let local_field = match &row.local_cell { + Some(cell) => format!( + "{}{}{}", + content.apply_to("["), + cell.style.apply_to(pad(&cell.text, w.local)), + content.apply_to("]"), + ), + None => " ".repeat(w.local + 2), + }; + + let sync_field = format!( + "{}{}{}", + content.apply_to("["), + row.sync_cell + .style + .apply_to(pad(&row.sync_cell.text, w.sync)), + content.apply_to("]"), + ); + + let remote_field = format!( + "{}{}{}", + content.apply_to("["), + row.remote_cell + .style + .apply_to(pad(&row.remote_cell.text, w.remote)), + content.apply_to("]"), + ); + + format!( + "{} {} {} {} {} {} {}", + repo_field, + conn.apply_to(pad(row.connector_left, 4)), + local_field, + conn.apply_to(pad(row.connector_mid, 4)), + sync_field, + conn.apply_to(pad(row.connector_right, 4)), + remote_field, + ) +} diff --git a/pkgs/flake-repos/src/status.rs b/pkgs/flake-repos/src/status.rs new file mode 100644 index 0000000..c26cfa6 --- /dev/null +++ b/pkgs/flake-repos/src/status.rs @@ -0,0 +1,99 @@ +use crate::model::Repo; +use crate::vcs; +use anyhow::Result; +use std::path::Path; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum LocalState { + Clean, + Dirty(usize), +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum SyncState { + InSync, + Ahead, + Behind, + Diverged, + Missing, +} + +#[derive(Debug, Clone)] +pub struct RemoteStatus { + pub name: String, + pub sync: SyncState, +} + +#[derive(Debug, Clone)] +pub enum RepoStatus { + NotPresent, + Present { + local_state: LocalState, + remotes: Vec, + }, +} + +pub fn collect_status(projects_root: &Path, repo: &Repo) -> Result { + let path = projects_root.join(&repo.local); + + if !path.exists() { + return Ok(RepoStatus::NotPresent); + } + + let local_state = if vcs::jj_is_dirty(&path)? { + LocalState::Dirty(vcs::jj_dirty_file_count(&path)?) + } else { + LocalState::Clean + }; + + let bookmark = vcs::primary_bookmark(&path)?; + + let mut remotes = Vec::with_capacity(repo.remotes.len()); + for name in repo.remotes.keys() { + let sync = match &bookmark { + None => SyncState::Missing, + Some(bm) => compute_sync_state(&path, bm, name)?, + }; + remotes.push(RemoteStatus { + name: name.clone(), + sync, + }); + } + + Ok(RepoStatus::Present { + local_state, + remotes, + }) +} + +fn compute_sync_state(path: &Path, bookmark: &str, remote: &str) -> Result { + let local_rev = bookmark; + let remote_rev = format!("{bookmark}@{remote}"); + + let remote_id = vcs::resolve_commit(path, &remote_rev)?; + if remote_id.is_none() { + return Ok(SyncState::Missing); + } + + let local_id = vcs::resolve_commit(path, local_rev)?; + if vcs::same_commit(local_id.as_deref(), remote_id.as_deref()) { + return Ok(SyncState::InSync); + } + + if vcs::is_ancestor(path, &remote_rev, local_rev)? { + return Ok(SyncState::Ahead); + } + + if vcs::is_ancestor(path, local_rev, &remote_rev)? { + return Ok(SyncState::Behind); + } + + Ok(SyncState::Diverged) +} + +pub fn collect_all(projects_root: &Path, repos: &[Repo]) -> Result> { + repos + .iter() + .map(|r| collect_status(projects_root, r).map(|s| (r.clone(), s))) + .collect() +} diff --git a/pkgs/flake-repos/src/vcs.rs b/pkgs/flake-repos/src/vcs.rs new file mode 100644 index 0000000..72c9f11 --- /dev/null +++ b/pkgs/flake-repos/src/vcs.rs @@ -0,0 +1,197 @@ +use anyhow::{bail, Context, Result}; +use std::collections::BTreeMap; +use std::path::Path; +use std::process::{Command, Output}; + +fn run(cwd: &Path, program: &str, args: &[&str]) -> Result { + let output = Command::new(program) + .args(args) + .current_dir(cwd) + .output() + .with_context(|| format!("failed to spawn `{program} {}`", args.join(" ")))?; + + check_output(program, args, &output)?; + Ok(String::from_utf8_lossy(&output.stdout).trim().to_string()) +} + +fn run_in(dir: &Path, program: &str, args: &[&str]) -> Result { + run(dir, program, args) +} + +fn check_output(program: &str, args: &[&str], output: &Output) -> Result<()> { + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + bail!( + "`{program} {}` failed ({}): {}", + args.join(" "), + output.status, + stderr.trim() + ); + } + Ok(()) +} + +pub fn is_git_repo(path: &Path) -> bool { + path.join(".git").exists() +} + +pub fn is_jj_initialized(path: &Path) -> bool { + path.join(".jj").exists() +} + +pub fn git_remotes(path: &Path) -> Result> { + let out = run(path, "git", &["remote", "-v"])?; + let mut remotes = BTreeMap::new(); + + for line in out.lines() { + let mut parts = line.split_whitespace(); + let (Some(name), Some(uri), Some(kind)) = (parts.next(), parts.next(), parts.next()) else { + continue; + }; + if kind == "(fetch)" { + remotes.insert(name.to_string(), uri.to_string()); + } + } + Ok(remotes) +} + +pub fn jj_init_colocate(path: &Path) -> Result<()> { + run(path, "jj", &["git", "init", "--colocate"]) + .with_context(|| format!("failed to jj-init {}", path.display()))?; + Ok(()) +} + +pub fn jj_clone(remote_uri: &str, dest: &Path) -> Result<()> { + let parent = dest + .parent() + .context("clone destination has no parent directory")?; + std::fs::create_dir_all(parent) + .with_context(|| format!("failed to create parent dir {}", parent.display()))?; + + let dest_str = dest.to_string_lossy(); + run_in( + parent, + "jj", + &["git", "clone", "--colocate", remote_uri, &dest_str], + ) + .with_context(|| format!("failed to clone {remote_uri} into {}", dest.display()))?; + Ok(()) +} + +pub fn git_remote_add(path: &Path, name: &str, uri: &str) -> Result<()> { + run(path, "git", &["remote", "add", name, uri])?; + Ok(()) +} + +pub fn jj_track_bookmark(path: &Path, bookmark: &str, remote: &str) -> Result<()> { + let remote_ref = format!("{bookmark}@{remote}"); + if is_bookmark_tracked(path, bookmark, remote)? { + return Ok(()); + } + run(path, "jj", &["bookmark", "track", &remote_ref]) + .with_context(|| format!("failed to track {remote_ref} in {}", path.display()))?; + Ok(()) +} + +fn is_bookmark_tracked(path: &Path, bookmark: &str, remote: &str) -> Result { + let out = run(path, "jj", &["bookmark", "list", "--tracked"])?; + Ok(out + .lines() + .any(|l| l.contains(&format!("{bookmark}@{remote}")))) +} + +pub fn jj_fetch(path: &Path, remote: &str) -> Result<()> { + run(path, "jj", &["git", "fetch", "--remote", remote]) + .with_context(|| format!("fetch from {remote} failed in {}", path.display()))?; + Ok(()) +} + +pub fn jj_push(path: &Path, remote: &str, allow_new: bool) -> Result<()> { + let mut args = vec!["git", "push", "--remote", remote]; + if allow_new { + args.push("--allow-new"); + } + run(path, "jj", &args) + .with_context(|| format!("push to {remote} failed in {}", path.display()))?; + Ok(()) +} + +pub fn jj_is_dirty(path: &Path) -> Result { + let out = run(path, "jj", &["diff", "--summary"])?; + Ok(!out.trim().is_empty()) +} + +pub fn jj_dirty_file_count(path: &Path) -> Result { + let out = run(path, "jj", &["diff", "--summary"])?; + Ok(out.lines().filter(|l| !l.trim().is_empty()).count()) +} + +pub fn resolve_commit(path: &Path, revset: &str) -> Result> { + let result = run( + path, + "jj", + &[ + "log", + "-r", + revset, + "--no-graph", + "-T", + "commit_id", + "--limit", + "1", + ], + ); + match result { + Ok(id) if !id.is_empty() => Ok(Some(id)), + Ok(_) => Ok(None), + Err(_) => Ok(None), + } +} + +pub fn same_commit(a: Option<&str>, b: Option<&str>) -> bool { + matches!((a, b), (Some(x), Some(y)) if x == y) +} + +pub fn is_ancestor(path: &Path, ancestor_revset: &str, descendant_revset: &str) -> Result { + let revset = format!("{ancestor_revset} & ::{descendant_revset}"); + let out = run( + path, + "jj", + &[ + "log", + "-r", + &revset, + "--no-graph", + "-T", + "commit_id", + "--limit", + "1", + ], + ); + Ok(matches!(out, Ok(s) if !s.is_empty())) +} + +pub fn primary_bookmark(path: &Path) -> Result> { + let out = run(path, "jj", &["bookmark", "list"])?; + let mut names: Vec = out + .lines() + .filter_map(|line| { + let name = line.split(':').next()?.trim(); + if name.is_empty() || name.contains('@') { + None + } else { + Some(name.to_string()) + } + }) + .collect(); + names.sort(); + names.dedup(); + + if names.iter().any(|n| n == "main") { + return Ok(Some("main".to_string())); + } + if names.iter().any(|n| n == "master") { + return Ok(Some("master".to_string())); + } + Ok(names.into_iter().next()) +} -- 2.51.2