feat: vcs: introduce the VCS module with Jujutsu as an implementation example

This commit is contained in:
Alexis (Poliorcetics) Bourget 2024-11-08 18:07:02 +01:00
parent af08ab4ce1
commit 19926e1e0a
No known key found for this signature in database
10 changed files with 733 additions and 0 deletions

View File

@ -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": {

View File

@ -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.

View File

@ -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>,

View File

@ -40,6 +40,7 @@ pub const PROMPT_ORDER: &[&str] = &[
"nats",
"directory",
"vcsh",
"vcs",
"fossil_branch",
"fossil_metrics",
"git_branch",

View File

@ -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: "»",
}
}
}

64
src/configs/vcs/mod.rs Normal file
View File

@ -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<Vcs>,
#[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)
}
}

View File

@ -97,6 +97,7 @@ pub const ALL_MODULES: &[&str] = &[
"typst",
"username",
"vagrant",
"vcs",
"vcsh",
"vlang",
"zig",

View File

@ -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<Module<'a>> {
"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",

152
src/modules/vcs/jujutsu.rs Normal file
View File

@ -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<Result<Vec<Segment>, 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<Result<Vec<Segment>, 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<Result<Vec<Segment>, 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)
}

119
src/modules/vcs/mod.rs Normal file
View File

@ -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<Module<'a>> {
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 `<dir>`, 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 `<dir>/<marker>`
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<F>(
format_str: &str,
config_path: &str,
context: &Context,
mapper: F,
) -> Option<Vec<Segment>>
where
F: Fn(&str) -> Option<String> + 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<Vec<Segment>> {
if count == 0 {
return None;
}
format_text(
format_str,
config_path,
context,
|variable| match variable {
"count" => Some(count.to_string()),
_ => None,
},
)
}