From 19926e1e0aa25eddf63f93ba270d60eef023338f Mon Sep 17 00:00:00 2001 From: "Alexis (Poliorcetics) Bourget" Date: Fri, 8 Nov 2024 18:07:02 +0100 Subject: [PATCH] feat: vcs: introduce the VCS module with Jujutsu as an implementation example --- .github/config-schema.json | 185 +++++++++++++++++++++++++++++++++++ docs/config/README.md | 122 +++++++++++++++++++++++ src/configs/mod.rs | 3 + src/configs/starship_root.rs | 1 + src/configs/vcs/jujutsu.rs | 83 ++++++++++++++++ src/configs/vcs/mod.rs | 64 ++++++++++++ src/module.rs | 1 + src/modules/mod.rs | 3 + src/modules/vcs/jujutsu.rs | 152 ++++++++++++++++++++++++++++ src/modules/vcs/mod.rs | 119 ++++++++++++++++++++++ 10 files changed, 733 insertions(+) create mode 100644 src/configs/vcs/jujutsu.rs create mode 100644 src/configs/vcs/mod.rs create mode 100644 src/modules/vcs/jujutsu.rs create mode 100644 src/modules/vcs/mod.rs diff --git a/.github/config-schema.json b/.github/config-schema.json index 3dc390b69..685290160 100644 --- a/.github/config-schema.json +++ b/.github/config-schema.json @@ -1859,6 +1859,37 @@ } ] }, + "vcs": { + "default": { + "disabled": true, + "jujutsu": { + "change": { + "change_id_length": 7, + "disabled": false, + "format": "on [$change_id]($style) ", + "style": "purple" + }, + "format": "$change$status", + "status": { + "added": "+", + "deleted": "✘", + "disabled": false, + "format": "[\\[$all_status\\]]($style) ", + "modified": "!", + "renamed": "»", + "style": "yellow" + } + }, + "order": [ + "jujutsu" + ] + }, + "allOf": [ + { + "$ref": "#/definitions/VcsConfig" + } + ] + }, "vcsh": { "default": { "disabled": false, @@ -6328,6 +6359,160 @@ }, "additionalProperties": false }, + "VcsConfig": { + "type": "object", + "properties": { + "disabled": { + "default": true, + "type": "boolean" + }, + "order": { + "default": [ + "jujutsu" + ], + "type": "array", + "items": { + "$ref": "#/definitions/Vcs" + }, + "uniqueItems": true + }, + "jujutsu": { + "default": { + "change": { + "change_id_length": 7, + "disabled": false, + "format": "on [$change_id]($style) ", + "style": "purple" + }, + "format": "$change$status", + "status": { + "added": "+", + "deleted": "✘", + "disabled": false, + "format": "[\\[$all_status\\]]($style) ", + "modified": "!", + "renamed": "»", + "style": "yellow" + } + }, + "allOf": [ + { + "$ref": "#/definitions/JjConfig" + } + ] + } + }, + "additionalProperties": false + }, + "Vcs": { + "type": "string", + "enum": [ + "jujutsu" + ] + }, + "JjConfig": { + "type": "object", + "required": [ + "format" + ], + "properties": { + "format": { + "type": "string" + }, + "change": { + "default": { + "change_id_length": 7, + "disabled": false, + "format": "on [$change_id]($style) ", + "style": "purple" + }, + "allOf": [ + { + "$ref": "#/definitions/JjChangeConfig" + } + ] + }, + "status": { + "default": { + "added": "+", + "deleted": "✘", + "disabled": false, + "format": "[\\[$all_status\\]]($style) ", + "modified": "!", + "renamed": "»", + "style": "yellow" + }, + "allOf": [ + { + "$ref": "#/definitions/JjStatusConfig" + } + ] + } + }, + "additionalProperties": false + }, + "JjChangeConfig": { + "type": "object", + "required": [ + "change_id_length", + "disabled", + "format", + "style" + ], + "properties": { + "disabled": { + "type": "boolean" + }, + "format": { + "type": "string" + }, + "style": { + "type": "string" + }, + "change_id_length": { + "type": "integer", + "format": "uint", + "minimum": 0.0 + } + }, + "additionalProperties": false + }, + "JjStatusConfig": { + "type": "object", + "required": [ + "added", + "deleted", + "disabled", + "format", + "modified", + "renamed", + "style" + ], + "properties": { + "disabled": { + "type": "boolean" + }, + "format": { + "type": "string" + }, + "style": { + "type": "string" + }, + "added": { + "type": "string" + }, + "deleted": { + "type": "string" + }, + "modified": { + "type": "string" + }, + "renamed": { + "type": "string" + } + }, + "additionalProperties": false + }, "VcshConfig": { "type": "object", "properties": { diff --git a/docs/config/README.md b/docs/config/README.md index 09d263340..20295f147 100644 --- a/docs/config/README.md +++ b/docs/config/README.md @@ -4684,6 +4684,128 @@ By default the module will be shown if any of the following conditions are met: format = 'via [V $version](blue bold) ' ``` +## VCS + +The `vcs` module displays the current active Version Control System (VCS). +The module will be shown only if a VCS is currently in use. + +### Options + +| Option | Default | Description | +| ----------- | ------------- | ----------------------------------- | +| `order` | `["jujutsu"]` | The order in which to search VCSes. | +| `jujutsu` | _see below_ | The Jujutsu configuration. | +| `mercurial` | _see below_ | The Mercurial configuration. | +| `disabled` | `true` | Disables the `vcs` module. | + +Note that an empty `order` has the same effect as disabling the module. + +VCS will be searched for following `order`. The first one that returns valid informations will be the one printed, +e.g. in a repository with both `.git` and `.jj` present, `order = ["jujutsu", "git"]` will always print Jujutsu's output. + +### Example + +```toml +# ~/.config/starship.toml + +[vcs] +order = [ + "jujutsu", +] +``` + +### VCS Options: Jujutsu + +| Option | Default | Description | +| -------- | ---------------- | ------------------------------------- | +| `format` | `$change$status` | The format of the Jujutsu VCS module. | +| `change` | _see below_ | The Jujutsu change configuration. | +| `status` | _see below_ | The Jujutsu status configuration. | + +#### Variables + +| Variable | Description | +| -------- | -------------------------- | +| `change` | The Jujutsu change module. | +| `status` | The Jujutsu status module. | + +#### Example + +```toml +# ~/.config/starship.toml + +[vcs.jujutsu] +format = "$status$change" +``` + +### VCS Options: Jujutsu: Change + +| Option | Default | Description | +| ------------------ | ---------------------------- | -------------------------------------------------------------------------------------------- | +| `change_id_length` | `7` | Minimum size of the unique prefix to use. Pass `0` to always use the shortest possible size. | +| `style` | `'purple'` | The style for the module. | +| `format` | `'on [$change_id]($style) '` | The format for the module. | +| `disabled` | `false` | Disables the `vcs.jujutsu.change` module. | + +#### Variables + +The following variables can be used in `format`: + +| Variable | Description | +| ----------- | ----------------------------------- | +| `change_id` | The Jujutsu current Change ID. | +| style\* | Mirrors the value of option `style` | + +*: This variable can only be used as a part of a style string + +#### Example + +```toml +# ~/.config/starship.toml + +[vcs.jujutsu.change] +format = "with [$change_id]($style)" +``` + +### VCS Options: Jujutsu: Status + +| Option | Default | Description | +| ---------- | -------------------------------- | ----------------------------------------- | +| `added` | `'+'` | The format of `added` | +| `deleted` | `'✘'` | The format of `deleted` | +| `modified` | `'!'` | The format of `modified` | +| `renamed` | `'»'` | The format of `renamed` | +| `style` | `'yellow'` | The style for the module. | +| `format` | `'[\\[$all_status\\]]($style) '` | The format for the module. | +| `disabled` | `false` | Disables the `vcs.jujutsu.status` module. | + +#### Variables + +The following variables can be used in `format`: + +| Variable | Description | +| ------------ | -------------------------------------------------- | +| `all_status` | Shortcut for`$deleted$renamed$modified$added` | +| `added` | Displays `added` when a new file has been added. | +| `deleted` | Displays `deleted` when a file has been deleted. | +| `modified` | Displays `modified` when a file has been modified. | +| `renamed` | Displays `renamed` when a file has been renamed. | +| style\* | Mirrors the value of option `style` | + +*: This variable can only be used as a part of a style string + +#### Example + +```toml +# ~/.config/starship.toml + +[vcs.jujutsu.status] +added = "A" +deleted = "D" +modified = "M" +renamed = "R" +``` + ## VCSH The `vcsh` module displays the current active [VCSH](https://github.com/RichiH/vcsh) repository. diff --git a/src/configs/mod.rs b/src/configs/mod.rs index 820d7f5b0..01ff271a8 100644 --- a/src/configs/mod.rs +++ b/src/configs/mod.rs @@ -94,6 +94,7 @@ pub mod typst; pub mod username; pub mod v; pub mod vagrant; +pub mod vcs; pub mod vcsh; pub mod zig; @@ -294,6 +295,8 @@ pub struct FullConfig<'a> { #[serde(borrow)] vagrant: vagrant::VagrantConfig<'a>, #[serde(borrow)] + vcs: vcs::VcsConfig<'a>, + #[serde(borrow)] vcsh: vcsh::VcshConfig<'a>, #[serde(borrow)] vlang: v::VConfig<'a>, diff --git a/src/configs/starship_root.rs b/src/configs/starship_root.rs index 8bbe0948b..cfdf2f73f 100644 --- a/src/configs/starship_root.rs +++ b/src/configs/starship_root.rs @@ -40,6 +40,7 @@ pub const PROMPT_ORDER: &[&str] = &[ "nats", "directory", "vcsh", + "vcs", "fossil_branch", "fossil_metrics", "git_branch", diff --git a/src/configs/vcs/jujutsu.rs b/src/configs/vcs/jujutsu.rs new file mode 100644 index 000000000..60befb02d --- /dev/null +++ b/src/configs/vcs/jujutsu.rs @@ -0,0 +1,83 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Deserialize, Serialize)] +#[cfg_attr( + feature = "config-schema", + derive(schemars::JsonSchema), + schemars(deny_unknown_fields) +)] +pub struct JjConfig<'a> { + pub format: &'a str, + #[serde(borrow, default)] + pub change: JjChangeConfig<'a>, + #[serde(borrow, default)] + pub status: JjStatusConfig<'a>, +} + +impl<'a> Default for JjConfig<'a> { + fn default() -> Self { + Self { + format: "$change$status", + change: Default::default(), + status: Default::default(), + } + } +} + +#[derive(Clone, Deserialize, Serialize)] +#[cfg_attr( + feature = "config-schema", + derive(schemars::JsonSchema), + schemars(deny_unknown_fields) +)] +pub struct JjChangeConfig<'a> { + pub disabled: bool, + pub format: &'a str, + pub style: &'a str, + + pub change_id_length: usize, +} + +impl<'a> Default for JjChangeConfig<'a> { + fn default() -> Self { + Self { + disabled: false, + format: "on [$change_id]($style) ", + style: "purple", + + change_id_length: 7, + } + } +} + +#[derive(Clone, Deserialize, Serialize)] +#[cfg_attr( + feature = "config-schema", + derive(schemars::JsonSchema), + schemars(deny_unknown_fields) +)] +pub struct JjStatusConfig<'a> { + pub disabled: bool, + pub format: &'a str, + pub style: &'a str, + + pub added: &'a str, + pub deleted: &'a str, + pub modified: &'a str, + pub renamed: &'a str, +} + +impl<'a> Default for JjStatusConfig<'a> { + fn default() -> Self { + Self { + disabled: false, + format: "[\\[$all_status\\]]($style) ", + style: "yellow", + + added: "+", + deleted: "✘", + modified: "!", + renamed: "»", + } + } +} diff --git a/src/configs/vcs/mod.rs b/src/configs/vcs/mod.rs new file mode 100644 index 000000000..cedc9693e --- /dev/null +++ b/src/configs/vcs/mod.rs @@ -0,0 +1,64 @@ +use indexmap::IndexSet; +use serde::{Deserialize, Serialize}; + +pub mod jujutsu; + +#[derive(Clone, Deserialize, Serialize)] +#[cfg_attr( + feature = "config-schema", + derive(schemars::JsonSchema), + schemars(deny_unknown_fields) +)] +#[serde(default)] +pub struct VcsConfig<'a> { + pub disabled: bool, + pub order: IndexSet, + #[serde(borrow, default)] + pub jujutsu: jujutsu::JjConfig<'a>, +} + +impl<'a> Default for VcsConfig<'a> { + fn default() -> Self { + Self { + disabled: true, + order: [ + // TODO(poliorcetics): make the default be only Git, avoiding costs for the + // vast majority of users. + Vcs::Jujutsu, + ] + .into_iter() + .collect(), + jujutsu: Default::default(), + } + } +} + +#[derive(Copy, Clone, Deserialize, Serialize, PartialEq, Eq, PartialOrd, Ord, Hash)] +#[cfg_attr( + feature = "config-schema", + derive(schemars::JsonSchema), + schemars(deny_unknown_fields) +)] +#[serde(rename_all = "lowercase")] +pub enum Vcs { + Jujutsu, +} + +impl Vcs { + /// Marker file or directory indicating the VCS is active. + pub const fn marker(&self) -> &'static str { + match self { + Self::Jujutsu => ".jj", + } + } +} + +impl std::fmt::Display for Vcs { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let s = match self { + Self::Jujutsu => "jujutsu", + }; + + f.write_str(s) + } +} diff --git a/src/module.rs b/src/module.rs index e5e0f392c..e9f8e0daf 100644 --- a/src/module.rs +++ b/src/module.rs @@ -97,6 +97,7 @@ pub const ALL_MODULES: &[&str] = &[ "typst", "username", "vagrant", + "vcs", "vcsh", "vlang", "zig", diff --git a/src/modules/mod.rs b/src/modules/mod.rs index 1301ce8f2..32143ba0c 100644 --- a/src/modules/mod.rs +++ b/src/modules/mod.rs @@ -89,6 +89,7 @@ mod time; mod username; mod utils; mod vagrant; +mod vcs; mod vcsh; mod vlang; mod zig; @@ -203,6 +204,7 @@ pub fn handle<'a>(module: &str, context: &'a Context) -> Option> { "username" => username::module(context), "vlang" => vlang::module(context), "vagrant" => vagrant::module(context), + "vcs" => vcs::module(context), "vcsh" => vcsh::module(context), "zig" => zig::module(context), env if env.starts_with("env_var.") => { @@ -326,6 +328,7 @@ pub fn description(module: &str) -> &'static str { "typst" => "The current installed version of typst", "username" => "The active user's username", "vagrant" => "The currently installed version of Vagrant", + "vcs" => "The currently active version control system", "vcsh" => "The currently active VCSH repository", "vlang" => "The currently installed version of V", "zig" => "The currently installed version of Zig", diff --git a/src/modules/vcs/jujutsu.rs b/src/modules/vcs/jujutsu.rs new file mode 100644 index 000000000..0890889aa --- /dev/null +++ b/src/modules/vcs/jujutsu.rs @@ -0,0 +1,152 @@ +use std::path::Path; + +use crate::configs::vcs::jujutsu::{JjChangeConfig, JjConfig, JjStatusConfig}; +use crate::formatter::string_formatter::StringFormatterError; + +use super::{format_count, Context, Segment, StringFormatter}; + +const ALL_STATUS_FORMAT: &str = "$deleted$renamed$modified$added"; + +#[derive(Default)] +struct Status { + added: usize, + deleted: usize, + modified: usize, + renamed: usize, +} + +pub(super) fn segments<'a>( + context: &Context<'a>, + root: &Path, + config: &JjConfig<'a>, +) -> Option, StringFormatterError>> { + // Prints something of the form: + // + // M src/configs/mod.rs + // A src/configs/vcs/jujutsu.rs + // vnyuwku + let template = format!( + "self.diff().summary() ++ '@ ' ++ change_id.shortest({})", + config.change.change_id_length + ); + let out = context.exec_cmd( + "jj", + &[ + "--repository".as_ref(), + root.as_os_str(), + "log".as_ref(), + "--ignore-working-copy".as_ref(), + "--no-graph".as_ref(), + "--color".as_ref(), + "never".as_ref(), + "--revisions".as_ref(), + "@".as_ref(), // Only display the current revision + "--template".as_ref(), + template.as_ref(), + ], + )?; + + let mut status = Status::default(); + let mut change_id = None; + + for line in out.stdout.lines() { + if line.is_empty() { + continue; + } + + let (indic, rest) = line.split_once(' ')?; + match indic { + "A" => status.added += 1, + "D" => status.deleted += 1, + "M" => status.modified += 1, + "R" => status.renamed += 1, + "@" => change_id = Some(rest), + _ => (), + } + } + + let parsed = StringFormatter::new(config.format).and_then(|formatter| { + formatter + .map_variables_to_segments(|variable| match variable { + "change" => change_segments(context, &config.change, change_id), + "status" => status_segments(context, &config.status, &status), + _ => None, + }) + .parse(None, Some(context)) + }); + + Some(parsed) +} + +fn change_segments( + context: &Context, + config: &JjChangeConfig, + change_id: Option<&str>, +) -> Option, StringFormatterError>> { + if config.disabled { + return None; + } + let change_id = change_id?; + + let parsed = StringFormatter::new(config.format).and_then(|formatter| { + formatter + .map_style(|variable: &str| match variable { + "style" => Some(Ok(config.style)), + _ => None, + }) + .map(|variable| (variable == "change_id").then_some(Ok(change_id))) + .parse(None, Some(context)) + }); + Some(parsed) +} + +fn status_segments( + context: &Context, + config: &JjStatusConfig, + status: &Status, +) -> Option, StringFormatterError>> { + if config.disabled { + return None; + } + + let parsed = StringFormatter::new(config.format).and_then(|formatter| { + formatter + .map_meta(|variable, _| match variable { + "all_status" => Some(ALL_STATUS_FORMAT), + _ => None, + }) + .map_style(|variable: &str| match variable { + "style" => Some(Ok(config.style)), + _ => None, + }) + .map_variables_to_segments(|variable| { + let segments = match variable { + "added" => { + format_count(config.added, "jujutsu.status.added", context, status.added) + } + "deleted" => format_count( + config.deleted, + "jujutsu.status.deleted", + context, + status.deleted, + ), + "modified" => format_count( + config.modified, + "jujutsu.status.modified", + context, + status.modified, + ), + "renamed" => format_count( + config.renamed, + "jujutsu.status.renamed", + context, + status.renamed, + ), + _ => None, + }; + segments.map(Ok) + }) + .parse(None, Some(context)) + }); + Some(parsed) +} diff --git a/src/modules/vcs/mod.rs b/src/modules/vcs/mod.rs new file mode 100644 index 000000000..ffb5f3e43 --- /dev/null +++ b/src/modules/vcs/mod.rs @@ -0,0 +1,119 @@ +//! The Version Control System (VCS) module exposes information from the currently active VCS, +//! trying them in a preconfigured order. +//! +//! This allows exposing information from only one VCS when several are present, as can be the case +//! for [colocated Git repos in Jujutsu][coloc]. +//! It also makes reusing already parsed repository information easier. +//! +//! [coloc]: https://martinvonz.github.io/jj/latest/git-compatibility/#co-located-jujutsugit-repos + +use std::path::{Path, PathBuf}; + +use crate::configs::vcs::{Vcs, VcsConfig}; +use crate::formatter::StringFormatter; +use crate::segment::Segment; + +use super::{Context, Module, ModuleConfig}; + +pub mod jujutsu; + +pub fn module<'a>(context: &'a Context) -> Option> { + let mut module = context.new_module("vcs"); + let config = VcsConfig::try_load(module.config); + + if config.disabled || config.order.is_empty() { + return None; + } + + let (root, vcs) = find_vcs_root(context, &config)?; + + let parsed = match vcs { + Vcs::Jujutsu => jujutsu::segments(context, root, &config.jujutsu)?, + }; + + module.set_segments(match parsed { + Ok(segments) if segments.is_empty() => return None, + Ok(segments) => segments, + Err(error) => { + log::warn!("Error in module `vcs.{vcs}`:\n{error}"); + return None; + } + }); + + Some(module) +} + +/// Find the closest VCS root marker by searching upwards. +fn find_vcs_root<'a>(context: &'a Context, config: &VcsConfig<'a>) -> Option<(&'a Path, Vcs)> { + let current_dir = &context.current_dir; + + // We want to avoid reallocations during the search so we pre-allocate a buffer with enough + // capacity to hold the longest path + any marker (we could find the length of the longest + // marker programmatically but that would actually cost more than preallocating a few bytes too many). + let mut buf = PathBuf::with_capacity(current_dir.capacity() + 15); + + for dir in current_dir.ancestors() { + // Then for each dir, we do ``, clearing in case `dir` is not an absolute path for + // some reason + buf.clear(); + buf.push(dir); + + for &vcs in &config.order { + // Then we push, so it becomes `/` + buf.push(vcs.marker()); + + if buf.exists() { + // In case we find it, we return the VCS but also the root dir: we already did the + // work of finding it, we can give it as a parameter to the called command or + // library so it can avoid doing its own search. + return Some((dir, vcs)); + } + + // Remove the current marker to prepare for the next one + buf.pop(); + } + } + + None +} + +fn format_text( + format_str: &str, + config_path: &str, + context: &Context, + mapper: F, +) -> Option> +where + F: Fn(&str) -> Option + Send + Sync, +{ + if let Ok(formatter) = StringFormatter::new(format_str) { + formatter + .map(|variable| mapper(variable).map(Ok)) + .parse(None, Some(context)) + .ok() + } else { + log::warn!("Error parsing format string `vcs.{}`", config_path); + None + } +} + +fn format_count( + format_str: &str, + config_path: &str, + context: &Context, + count: usize, +) -> Option> { + if count == 0 { + return None; + } + + format_text( + format_str, + config_path, + context, + |variable| match variable { + "count" => Some(count.to_string()), + _ => None, + }, + ) +}