Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 49 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ path = "src/main.rs"
anyhow = "1"
chrono = "0.4"
clap = { version = "4", features = ["derive"] }
dialoguer = "0.12.0"
git2 = { version = "0.20", default-features = false }
indexmap = { version = "2", features = ["serde"] }
pathdiff = "0.2"
Expand Down
57 changes: 32 additions & 25 deletions src/commands/deploy.rs
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,7 @@ pub fn run(repo_root: &Path, opts: DeployOptions) -> Result<()> {
if matches!(answer.trim().to_lowercase().as_str(), "n" | "no") {
bail!("aborted — commit or stash your changes and re-run");
}
let suggested = git::suggest_commit_msg(&repo);
let suggested = git::suggest_commit_msg(&dirty);
let prompt_msg = if let Some(ref s) = suggested {
log_info!("deploy suggested message: {}", crate::log::cyan(s));
eprint!(
Expand Down Expand Up @@ -169,36 +169,43 @@ pub fn run(repo_root: &Path, opts: DeployOptions) -> Result<()> {
tag
}
Mode::Interactive => {
let tags = git::list_deploy_tags(&repo)?;
if tags.is_empty() {
let tags_with_date = git::list_deploy_tags_with_date(&repo)?;
if tags_with_date.is_empty() {
bail!(
"no deploy/* tags found — run 'dogma deploy {} --new' first",
opts.env
);
}
log_info!("deploy available versions:");
for (i, tag) in tags.iter().enumerate() {
let marker = if i == 0 { " (latest)" } else { "" };
eprintln!(" [{}] {tag}{marker}", i + 1);
}
eprint!(
"{}select version to deploy to '{}' [1]: ",
crate::log::prompt_prefix(),
opts.env
);
io::stderr().flush()?;
let mut sel = String::new();
io::stdin().read_line(&mut sel)?;
let sel = sel.trim();
let idx: usize = if sel.is_empty() {
1
} else {
sel.parse().context("invalid selection")?
let items: Vec<String> = tags_with_date
.iter()
.enumerate()
.map(|(i, (t, date))| {
let date_part = if date.is_empty() {
String::new()
} else {
format!(" {date}")
};
if i == 0 {
format!("{t}{date_part} (latest)")
} else {
format!("{t}{date_part}")
}
})
.collect();
let theme = dialoguer::theme::ColorfulTheme {
active_item_style: dialoguer::console::Style::new().for_stderr().red(),
active_item_prefix: dialoguer::console::style(">".to_string())
.for_stderr()
.red(),
..dialoguer::theme::ColorfulTheme::default()
};
if idx < 1 || idx > tags.len() {
bail!("selection out of range: {idx}");
}
let tag = tags[idx - 1].clone();
let idx = dialoguer::Select::with_theme(&theme)
.with_prompt(format!("select version to deploy to '{}'", opts.env))
.items(&items)
.default(0)
.max_length(10)
.interact_on(&dialoguer::console::Term::stderr())?;
let tag = tags_with_date[idx].0.clone();
log_info!("deploy selected: {tag}");
log_info!("deploy checking out {tag} (detached HEAD) ...");
git::checkout_tag(&repo, &tag)?;
Expand Down
2 changes: 1 addition & 1 deletion src/commands/infra.rs
Original file line number Diff line number Diff line change
Expand Up @@ -197,7 +197,7 @@ fn maybe_commit_dirty(
if matches!(answer.trim().to_lowercase().as_str(), "n" | "no") {
bail!("aborted — commit or stash your changes and re-run");
}
let suggested = git::suggest_commit_msg(repo);
let suggested = git::suggest_commit_msg(&dirty);
let prompt_msg = if let Some(ref s) = suggested {
log_info!("infra suggested message: {}", crate::log::cyan(s));
eprint!(
Expand Down
80 changes: 59 additions & 21 deletions src/git/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,57 @@ pub fn list_deploy_tags(repo: &Repository) -> Result<Vec<String>> {
Ok(tags)
}

/// Returns deploy tags sorted newest-first, each paired with a formatted date
/// string (`YYYY-MM-DD`). Falls back to the commit date when the tag is
/// lightweight (no tag object), and to an empty string on any error.
pub fn list_deploy_tags_with_date(
repo: &Repository,
) -> Result<Vec<(String, String)>> {
let tags = list_deploy_tags(repo)?;
let pairs = tags
.into_iter()
.map(|name| {
let date = tag_date(repo, &name).unwrap_or_default();
(name, date)
})
.collect();
Ok(pairs)
}

fn tag_date(repo: &Repository, name: &str) -> Option<String> {
let obj = repo.revparse_single(name).ok()?;
let time = if let Ok(tag) = obj.clone().into_tag() {
tag.tagger()?.when()
} else {
obj.peel_to_commit().ok()?.time()
};
let secs = time.seconds();
let dt = chrono::DateTime::<chrono::Utc>::from_timestamp(secs, 0)?
.with_timezone(&chrono::Local);
let now = chrono::Local::now();
let diff = now.signed_duration_since(dt);
let (n, unit) = if diff.num_seconds() < 60 {
(0u64, "just now")
} else if diff.num_minutes() < 60 {
(diff.num_minutes() as u64, "m ago")
} else if diff.num_hours() < 24 {
(diff.num_hours() as u64, "h ago")
} else if diff.num_days() < 30 {
(diff.num_days() as u64, "d ago")
} else if diff.num_days() < 365 {
((diff.num_days() / 30) as u64, "mo ago")
} else {
((diff.num_days() / 365) as u64, "y ago")
};
let rel = if unit == "just now" {
"just now".to_string()
} else {
format!("{n:>2}{unit}")
};
let relative = format!("{rel:<9}");
Some(format!("{} {}", dt.format("%Y-%m-%d %H:%M"), relative))
}

pub fn tag_exists(repo: &Repository, name: &str) -> Result<bool> {
Ok(
repo
Expand Down Expand Up @@ -313,31 +364,18 @@ fn push_refspec(repo: &Repository, refspec: &str) -> Result<()> {
// Suggest commit message (heuristic, mirrors suggest-commit-msg.sh)
// ---------------------------------------------------------------------------

pub fn suggest_commit_msg(repo: &Repository) -> Option<String> {
let mut opts = git2::DiffOptions::new();
opts.include_untracked(true);
let diff = repo.diff_index_to_workdir(None, Some(&mut opts)).ok()?;

let mut files: Vec<String> = Vec::new();
diff
.foreach(
&mut |delta, _| {
if let Some(p) = delta.new_file().path() {
files.push(p.to_string_lossy().to_string());
}
true
},
None,
None,
None,
)
.ok()?;
pub fn suggest_commit_msg(dirty: &DirtyFiles) -> Option<String> {
let files: Vec<String> = dirty.files.iter().map(|f| f.path.clone()).collect();

if files.is_empty() {
return None;
}

let commit_type = infer_type(&files);
let all_new = dirty
.files
.iter()
.all(|f| f.status == 'A' || f.status == '?');
let commit_type = if all_new { "feat" } else { infer_type(&files) };
let scope = infer_scope(&files);
let desc = files
.iter()
Expand Down Expand Up @@ -393,7 +431,7 @@ fn infer_type(files: &[String]) -> &'static str {
return "chore";
}
}
"fix"
"chore"
}

fn infer_scope(files: &[String]) -> Option<String> {
Expand Down
Loading