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",
|
"anyhow",
|
||||||
"async-trait",
|
"async-trait",
|
||||||
"bytes",
|
"bytes",
|
||||||
|
"chrono",
|
||||||
"clap",
|
"clap",
|
||||||
"crossterm",
|
"crossterm",
|
||||||
"dirs 6.0.0",
|
"dirs 6.0.0",
|
||||||
@@ -786,6 +787,7 @@ dependencies = [
|
|||||||
"ratatui",
|
"ratatui",
|
||||||
"regex",
|
"regex",
|
||||||
"serde",
|
"serde",
|
||||||
|
"serde_json",
|
||||||
"shell-words",
|
"shell-words",
|
||||||
"termwiz 0.23.3",
|
"termwiz 0.23.3",
|
||||||
"tokio",
|
"tokio",
|
||||||
|
|||||||
@@ -40,6 +40,8 @@ regex = "1.11.1"
|
|||||||
minijinja = "2"
|
minijinja = "2"
|
||||||
shell-words = "1"
|
shell-words = "1"
|
||||||
openssl = { version = "0.10", features = ["vendored"], optional = true }
|
openssl = { version = "0.10", features = ["vendored"], optional = true }
|
||||||
|
chrono = { version = "0.4", features = ["serde"] }
|
||||||
|
serde_json = "1"
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
pretty_assertions = "1.4.0"
|
pretty_assertions = "1.4.0"
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ use crate::{
|
|||||||
chooser::Chooser,
|
chooser::Chooser,
|
||||||
custom_command::CustomCommandApp,
|
custom_command::CustomCommandApp,
|
||||||
interactive::{InteractiveApp, Searchable},
|
interactive::{InteractiveApp, Searchable},
|
||||||
|
project_metadata::{ProjectMetadata, RepoEntry},
|
||||||
shell::ShellApp,
|
shell::ShellApp,
|
||||||
template_command,
|
template_command,
|
||||||
};
|
};
|
||||||
@@ -84,11 +85,15 @@ pub struct ProjectDeleteCommand {
|
|||||||
struct DirEntry {
|
struct DirEntry {
|
||||||
name: String,
|
name: String,
|
||||||
path: PathBuf,
|
path: PathBuf,
|
||||||
|
metadata: Option<ProjectMetadata>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Searchable for DirEntry {
|
impl Searchable for DirEntry {
|
||||||
fn display_label(&self) -> String {
|
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")
|
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>> {
|
fn list_subdirectories(dir: &Path) -> anyhow::Result<Vec<DirEntry>> {
|
||||||
if !dir.exists() {
|
if !dir.exists() {
|
||||||
return Ok(Vec::new());
|
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)? {
|
for entry in std::fs::read_dir(dir)? {
|
||||||
let entry = entry?;
|
let entry = entry?;
|
||||||
if entry.file_type()?.is_dir() {
|
if entry.file_type()?.is_dir() {
|
||||||
|
let path = entry.path();
|
||||||
|
let metadata = ProjectMetadata::load(&path);
|
||||||
entries.push(DirEntry {
|
entries.push(DirEntry {
|
||||||
name: entry.file_name().to_string_lossy().to_string(),
|
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)
|
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);
|
eprintln!(" applying template '{}'...", template.name);
|
||||||
copy_dir_recursive(&template.path, &project_path)?;
|
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!(
|
eprintln!(
|
||||||
"project '{}' created at {} with {} repositories",
|
"project '{}' created at {} with {} repositories",
|
||||||
@@ -437,6 +466,12 @@ impl ProjectAddCommand {
|
|||||||
|
|
||||||
clone_repos_into(app, &selected_repos, &project.path).await?;
|
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!(
|
eprintln!(
|
||||||
"added {} repositories to project '{}'",
|
"added {} repositories to project '{}'",
|
||||||
selected_repos.len(),
|
selected_repos.len(),
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ mod fuzzy_matcher;
|
|||||||
mod git_clone;
|
mod git_clone;
|
||||||
mod git_provider;
|
mod git_provider;
|
||||||
mod interactive;
|
mod interactive;
|
||||||
|
mod project_metadata;
|
||||||
mod projects_list;
|
mod projects_list;
|
||||||
mod shell;
|
mod shell;
|
||||||
mod template_command;
|
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