feat: add project metadata with .gitnow.json
Some checks failed
Release / release (push) Failing after 5s

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.
This commit is contained in:
2026-03-20 15:25:50 +01:00
parent b8424d595b
commit fbedae4159
5 changed files with 170 additions and 6 deletions

2
Cargo.lock generated
View File

@@ -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",

View File

@@ -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"

View File

@@ -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<ProjectMetadata>,
}
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<Vec<DirEntry>> {
if !dir.exists() {
return Ok(Vec::new());
@@ -135,13 +142,28 @@ fn list_subdirectories(dir: &Path) -> anyhow::Result<Vec<DirEntry>> {
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<RepoEntry> = 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<RepoEntry> = selected_repos.iter().map(RepoEntry::from).collect();
metadata.add_repositories(new_entries);
metadata.save(&project.path)?;
}
eprintln!(
"added {} repositories to project '{}'",
selected_repos.len(),

View File

@@ -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;

View File

@@ -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<Utc>,
#[serde(skip_serializing_if = "Option::is_none")]
pub template: Option<String>,
pub repositories: Vec<RepoEntry>,
}
#[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<String>,
repositories: Vec<RepoEntry>,
) -> Self {
Self {
version: 1,
name,
created_at: Utc::now(),
template,
repositories,
}
}
pub fn load(project_dir: &Path) -> Option<Self> {
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<RepoEntry>) {
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()
}
}