feat: add project metadata with .gitnow.json
Some checks failed
Release / release (push) Failing after 5s
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:
2
Cargo.lock
generated
2
Cargo.lock
generated
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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;
|
||||
|
||||
124
crates/gitnow/src/project_metadata.rs
Normal file
124
crates/gitnow/src/project_metadata.rs
Normal 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()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user