Sprint B.5: add-LICENSE action (MIT/Apache-2.0) in [m] menu + vulp repo license

- src/license/: embedded templates, holder resolution (config > git user.name
  > account username), write path shared by TUI and CLI
- TUI: bulk-aware [m] entry over action_targets(), kind select + confirm,
  point-updates has_license/is_dirty, logs to activity (new License kind)
- CLI: vulp repo license <repo>...|--all [--kind] [--holder] [--json],
  skips already-licensed repos, exit 2/4 contract kept
- config: license_holder + default_license fields (TUI-editable)
- never overwrites an existing license, never auto-commits
This commit is contained in:
dualfroz committed 2026-07-06 15:37:25 +02:00
1 parent 0468c032c6
commit f1432d1cd3
15 files changed
+506 -7

No files matched your search

+75 -1
View File
@@ -6,7 +6,7 @@ use super::modals_spawn::{
spawn_convention_preview, spawn_create_issue, spawn_create_pr, spawn_find_usages,
spawn_list_functions, spawn_repo_stats, spawn_rename_preview,
};
use super::state::{HubState, MenuAction, Modal, PhaseResult, SelectTag, SortMode, TextTag};
use super::state::{ConfirmTag, HubState, MenuAction, Modal, PhaseResult, SelectTag, SortMode, TextTag};
/// Handles one step of a sort/filter, repo-creation, sync, Quick Actions, or
/// code-intel modal chain. Returns `None` once `modal` has been consumed by
@@ -157,6 +157,25 @@ pub fn try_confirm(state: &mut HubState, modal: Modal) -> Option<Modal> {
}
}
Some(MenuAction::CodeIntelPicker) => super::modals_spawn::open_codeintel_picker(state),
Some(MenuAction::AddLicense) => {
let targets: Vec<usize> = state.action_targets().into_iter()
.filter(|&ri| !state.repos[ri].has_license)
.collect();
if targets.is_empty() {
state.status_msg = Some("All targeted repos already have a LICENSE".into());
} else {
let cursor = match crate::license::default_kind(&state.cfg) {
crate::license::LicenseKind::Apache2 => 1,
crate::license::LicenseKind::Mit => 0,
};
state.modal = Modal::Select {
title: format!("Add LICENSE — {} repo{}", targets.len(), if targets.len() == 1 { "" } else { "s" }),
options: vec!["MIT".into(), "Apache-2.0".into()],
cursor,
tag: SelectTag::LicenseKind { targets },
};
}
}
Some(MenuAction::StashSave(ri)) | Some(MenuAction::StashPop(ri)) => {
let ri = *ri;
let repo = state.repos.get(ri)?;
@@ -187,6 +206,61 @@ pub fn try_confirm(state: &mut HubState, modal: Modal) -> Option<Modal> {
None
}
// ── Add LICENSE ([m] action, bulk over action_targets) ────────────────
Modal::Select { cursor, tag: SelectTag::LicenseKind { targets }, .. } => {
let kind = if cursor == 1 { crate::license::LicenseKind::Apache2 } else { crate::license::LicenseKind::Mit };
let names: Vec<&str> = targets.iter().take(6)
.filter_map(|&ri| state.repos.get(ri).map(|r| r.name.as_str()))
.collect();
let more = targets.len().saturating_sub(names.len());
let list = if more > 0 {
format!("{} +{} more", names.join(", "), more)
} else {
names.join(", ")
};
state.modal = Modal::Confirm {
message: format!(
"Write {} LICENSE to {} repo{}? ({}) — not committed",
kind.label(), targets.len(), if targets.len() == 1 { "" } else { "s" }, list,
),
tag: ConfirmTag::WriteLicense { kind, targets },
};
None
}
Modal::Confirm { tag: ConfirmTag::WriteLicense { kind, targets }, .. } => {
let holder = crate::license::holder_for(&state.cfg);
let year = chrono::Datelike::year(&chrono::Utc::now());
let (mut written, mut skipped, mut failed) = (0usize, 0usize, 0usize);
for ri in targets {
let Some(repo) = state.repos.get(ri) else { continue };
let (path, name) = (repo.path.clone(), repo.name.clone());
match crate::license::write_license(&path, kind, &holder, year) {
Ok(crate::license::WriteOutcome::Written) => {
written += 1;
state.repos[ri].has_license = true;
// New untracked LICENSE file — the repo is dirty now.
state.repos[ri].is_dirty = true;
crate::activity::log(&crate::activity::ActivityEntry {
ts: chrono::Utc::now().timestamp(),
kind: crate::activity::ActivityKind::License,
repo: name,
detail: format!("{} ({})", kind.label(), holder),
});
}
Ok(crate::license::WriteOutcome::AlreadyLicensed) => {
skipped += 1;
state.repos[ri].has_license = true;
}
Err(_) => failed += 1,
}
}
let mut msg = format!("LICENSE ({}): {} written", kind.label(), written);
if skipped > 0 { msg.push_str(&format!(", {} already licensed", skipped)); }
if failed > 0 { msg.push_str(&format!(", {} FAILED", failed)); }
state.status_msg = Some(msg);
None
}
Modal::Text { value, tag: TextTag::PrRepo, .. } => {
let repo = value.trim().to_string();
if repo.is_empty() { return None; }
+6
View File
@@ -609,6 +609,12 @@ pub fn open_action_menu(state: &mut HubState) {
entries.push(entry("Delete project", "Del", kc(KeyCode::Delete)));
}
// Bulk-aware (multi-select > cursor repo > project): only shown when at
// least one targeted repo is missing a license file.
if state.action_targets().iter().any(|&ri| !state.repos[ri].has_license) {
entries.push(entry("Add LICENSE (MIT/Apache)", "", MenuAction::AddLicense));
}
match state.tab {
Tab::Todos => {
entries.push(entry("Cycle todo status", "Space", kc(KeyCode::Char(' '))));
+6
View File
@@ -81,6 +81,8 @@ pub enum MenuAction {
/// `git stash` / `git stash pop` on the repo under the cursor.
StashSave(usize),
StashPop(usize),
/// Write a LICENSE file into every `action_targets()` repo missing one.
AddLicense,
}
/// What a `Modal::Select` list is *for*, mirroring `TextTag`.
@@ -121,6 +123,8 @@ pub enum SelectTag {
ProfileScope(ProfileScopeAction),
// Contextual `[m]` action menu — options paired 1:1 with the actions
ActionMenu(Vec<MenuAction>),
// add-LICENSE flow: MIT vs Apache-2.0, for the captured target repos
LicenseKind { targets: Vec<usize> },
// Code intelligence (Stats tab, [m])
CodeIntelRepoPick,
CodeIntelAction { repo: std::path::PathBuf, name: String },
@@ -154,6 +158,8 @@ pub enum ConfirmTag {
DeleteTodo(usize),
RemoveGitAll,
DeleteTemplate(usize),
// write a LICENSE (of the chosen kind) into each target repo
WriteLicense { kind: crate::license::LicenseKind, targets: Vec<usize> },
}
/// Outcome of one key event while a modal is open, returned by
+1
View File
@@ -27,6 +27,7 @@ fn kind_color(kind: crate::activity::ActivityKind) -> Color {
K::Todo => Color::Yellow,
K::Pr => Color::Cyan,
K::Issue => Color::Yellow,
K::License => Color::Magenta,
}
}
+1
View File
@@ -185,6 +185,7 @@ pub fn render_activity_global(f: &mut Frame, state: &HubState, area: Rect) {
crate::activity::ActivityKind::Todo => Style::default().fg(Color::Yellow),
crate::activity::ActivityKind::Pr => Style::default().fg(Color::Cyan),
crate::activity::ActivityKind::Issue => Style::default().fg(Color::Yellow),
crate::activity::ActivityKind::License => Style::default().fg(Color::Magenta),
};
let detail = e.detail.chars().take(detail_w).collect::<String>();
Row::new(vec![