From fbedae4159759da590317cb7d622c767ff41077f Mon Sep 17 00:00:00 2001 From: kjuulh Date: Fri, 20 Mar 2026 15:25:50 +0100 Subject: [PATCH] feat: add project metadata with .gitnow.json Store creation time, template, and repository info in a .gitnow.json file when projects are created via CLI. Project listing now shows human-readable relative times (e.g. "3 days ago") and sorts by creation date. Metadata is updated when adding repos to existing projects and gracefully ignored for pre-existing projects. --- Cargo.lock | 2 + crates/gitnow/Cargo.toml | 2 + crates/gitnow/src/commands/project.rs | 47 ++++++++-- crates/gitnow/src/main.rs | 1 + crates/gitnow/src/project_metadata.rs | 124 ++++++++++++++++++++++++++ 5 files changed, 170 insertions(+), 6 deletions(-) create mode 100644 crates/gitnow/src/project_metadata.rs diff --git a/Cargo.lock b/Cargo.lock index f361dee..504f0f9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -770,6 +770,7 @@ dependencies = [ "anyhow", "async-trait", "bytes", + "chrono", "clap", "crossterm", "dirs 6.0.0", @@ -786,6 +787,7 @@ dependencies = [ "ratatui", "regex", "serde", + "serde_json", "shell-words", "termwiz 0.23.3", "tokio", diff --git a/crates/gitnow/Cargo.toml b/crates/gitnow/Cargo.toml index 4ce7d0b..0d209d1 100644 --- a/crates/gitnow/Cargo.toml +++ b/crates/gitnow/Cargo.toml @@ -40,6 +40,8 @@ regex = "1.11.1" minijinja = "2" shell-words = "1" openssl = { version = "0.10", features = ["vendored"], optional = true } +chrono = { version = "0.4", features = ["serde"] } +serde_json = "1" [dev-dependencies] pretty_assertions = "1.4.0" diff --git a/crates/gitnow/src/commands/project.rs b/crates/gitnow/src/commands/project.rs index 186a752..45b698e 100644 --- a/crates/gitnow/src/commands/project.rs +++ b/crates/gitnow/src/commands/project.rs @@ -8,6 +8,7 @@ use crate::{ chooser::Chooser, custom_command::CustomCommandApp, interactive::{InteractiveApp, Searchable}, + project_metadata::{ProjectMetadata, RepoEntry}, shell::ShellApp, template_command, }; @@ -84,11 +85,15 @@ pub struct ProjectDeleteCommand { struct DirEntry { name: String, path: PathBuf, + metadata: Option, } impl Searchable for DirEntry { fn display_label(&self) -> String { - self.name.clone() + match &self.metadata { + Some(meta) => format!("{} ({})", self.name, meta.created_ago()), + None => self.name.clone(), + } } } @@ -125,7 +130,9 @@ fn get_templates_dir(app: &'static App) -> PathBuf { resolve_dir(configured, ".gitnow/templates") } -/// List subdirectories of `dir` as `DirEntry` items, sorted by name. +/// List subdirectories of `dir` as `DirEntry` items. +/// Projects with metadata are sorted by creation time (most recent first), +/// followed by projects without metadata sorted alphabetically. fn list_subdirectories(dir: &Path) -> anyhow::Result> { if !dir.exists() { return Ok(Vec::new()); @@ -135,13 +142,28 @@ fn list_subdirectories(dir: &Path) -> anyhow::Result> { for entry in std::fs::read_dir(dir)? { let entry = entry?; if entry.file_type()?.is_dir() { + let path = entry.path(); + let metadata = ProjectMetadata::load(&path); entries.push(DirEntry { name: entry.file_name().to_string_lossy().to_string(), - path: entry.path(), + path, + metadata, }); } } - entries.sort_by(|a, b| a.name.cmp(&b.name)); + + entries.sort_by(|a, b| { + match (&a.metadata, &b.metadata) { + // Both have metadata: most recent first + (Some(a_meta), Some(b_meta)) => b_meta.created_at.cmp(&a_meta.created_at), + // Metadata projects come before non-metadata ones + (Some(_), None) => std::cmp::Ordering::Less, + (None, Some(_)) => std::cmp::Ordering::Greater, + // Both without metadata: alphabetical + (None, None) => a.name.cmp(&b.name), + } + }); + Ok(entries) } @@ -388,10 +410,17 @@ impl ProjectCreateCommand { } }; - if let Some(template) = template { + let template_name = if let Some(template) = template { eprintln!(" applying template '{}'...", template.name); copy_dir_recursive(&template.path, &project_path)?; - } + Some(template.name.clone()) + } else { + None + }; + + let repo_entries: Vec = selected_repos.iter().map(RepoEntry::from).collect(); + let metadata = ProjectMetadata::new(dir_name.clone(), template_name, repo_entries); + metadata.save(&project_path)?; eprintln!( "project '{}' created at {} with {} repositories", @@ -437,6 +466,12 @@ impl ProjectAddCommand { clone_repos_into(app, &selected_repos, &project.path).await?; + if let Some(mut metadata) = ProjectMetadata::load(&project.path) { + let new_entries: Vec = selected_repos.iter().map(RepoEntry::from).collect(); + metadata.add_repositories(new_entries); + metadata.save(&project.path)?; + } + eprintln!( "added {} repositories to project '{}'", selected_repos.len(), diff --git a/crates/gitnow/src/main.rs b/crates/gitnow/src/main.rs index 69a7c1f..be7f040 100644 --- a/crates/gitnow/src/main.rs +++ b/crates/gitnow/src/main.rs @@ -22,6 +22,7 @@ mod fuzzy_matcher; mod git_clone; mod git_provider; mod interactive; +mod project_metadata; mod projects_list; mod shell; mod template_command; diff --git a/crates/gitnow/src/project_metadata.rs b/crates/gitnow/src/project_metadata.rs new file mode 100644 index 0000000..486d8ef --- /dev/null +++ b/crates/gitnow/src/project_metadata.rs @@ -0,0 +1,124 @@ +use std::path::Path; + +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; + +use crate::git_provider::Repository; + +pub const METADATA_FILENAME: &str = ".gitnow.json"; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ProjectMetadata { + pub version: u32, + pub name: String, + pub created_at: DateTime, + #[serde(skip_serializing_if = "Option::is_none")] + pub template: Option, + pub repositories: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct RepoEntry { + pub provider: String, + pub owner: String, + pub repo_name: String, + pub ssh_url: String, +} + +impl From<&Repository> for RepoEntry { + fn from(repo: &Repository) -> Self { + Self { + provider: repo.provider.clone(), + owner: repo.owner.clone(), + repo_name: repo.repo_name.clone(), + ssh_url: repo.ssh_url.clone(), + } + } +} + +impl ProjectMetadata { + pub fn new( + name: String, + template: Option, + repositories: Vec, + ) -> Self { + Self { + version: 1, + name, + created_at: Utc::now(), + template, + repositories, + } + } + + pub fn load(project_dir: &Path) -> Option { + let path = project_dir.join(METADATA_FILENAME); + let content = std::fs::read_to_string(&path).ok()?; + let metadata: Self = serde_json::from_str(&content).ok()?; + Some(metadata) + } + + pub fn save(&self, project_dir: &Path) -> anyhow::Result<()> { + let path = project_dir.join(METADATA_FILENAME); + let content = serde_json::to_string_pretty(self)?; + std::fs::write(path, content)?; + Ok(()) + } + + pub fn add_repositories(&mut self, repos: Vec) { + for repo in repos { + if !self.repositories.iter().any(|r| r.ssh_url == repo.ssh_url) { + self.repositories.push(repo); + } + } + } + + pub fn created_ago(&self) -> String { + let duration = Utc::now().signed_duration_since(self.created_at); + + let days = duration.num_days(); + if days > 365 { + let years = days / 365; + return if years == 1 { + "1 year ago".into() + } else { + format!("{years} years ago") + }; + } + if days > 30 { + let months = days / 30; + return if months == 1 { + "1 month ago".into() + } else { + format!("{months} months ago") + }; + } + if days > 0 { + return if days == 1 { + "1 day ago".into() + } else { + format!("{days} days ago") + }; + } + + let hours = duration.num_hours(); + if hours > 0 { + return if hours == 1 { + "1 hour ago".into() + } else { + format!("{hours} hours ago") + }; + } + + let minutes = duration.num_minutes(); + if minutes > 0 { + return if minutes == 1 { + "1 minute ago".into() + } else { + format!("{minutes} minutes ago") + }; + } + + "just now".into() + } +}