From aaf31113116475c4a0150f7509c830735b5c4282 Mon Sep 17 00:00:00 2001 From: Amey Pawar Date: Sat, 6 Jun 2026 15:04:34 +0530 Subject: [PATCH 1/3] feat: add `parse::format` to normalize git-config whitespace. (#2594) Adds `gix_config::parse::format::normalize(input, &Options)`, which re-emits a single config file with sanitized whitespace without resolving includes. Values, comments and section headers are preserved verbatim; only insignificant whitespace, the `=` separator and newlines are rewritten. `Options` controls indentation (two spaces by default; tabs or none also available), spacing around `=`, the newline sequence (detect/LF/CRLF), the trailing newline, and optional blank-line collapsing. This is the library portion; the CLI will follow separately. Co-authored-by: Claude --- gix-config/src/parse/format.rs | 193 ++++++++++++++++++++++++ gix-config/src/parse/mod.rs | 2 + gix-config/tests/config/parse/format.rs | 185 +++++++++++++++++++++++ gix-config/tests/config/parse/mod.rs | 1 + 4 files changed, 381 insertions(+) create mode 100644 gix-config/src/parse/format.rs create mode 100644 gix-config/tests/config/parse/format.rs diff --git a/gix-config/src/parse/format.rs b/gix-config/src/parse/format.rs new file mode 100644 index 00000000000..5b29b353d24 --- /dev/null +++ b/gix-config/src/parse/format.rs @@ -0,0 +1,193 @@ +//! Reformat a git-config file with normalized, sanitized whitespace. +//! +//! This operates purely on the syntactic [event stream](crate::parse::Events) of a single +//! file. `include`/`includeIf` directives are *never* resolved here - those are only acted upon +//! when constructing a [`File`](crate::File) - so the formatter is "flat" by construction. +//! +//! Values, comments and section headers are reproduced verbatim; only insignificant whitespace, +//! newlines and the `=` separator are rewritten according to [`Options`](crate::parse::format::Options). + +use bstr::BString; + +use crate::parse::{self, Event}; + +/// How key/value lines beneath a section header are indented. +#[derive(Debug, Clone, Eq, PartialEq)] +pub enum Indentation { + /// A single horizontal tab per line - git's de-facto writer style. + Tab, + /// The given number of spaces per line. + Spaces(usize), + /// No indentation at all. + None, +} + +/// Which newline sequence to write between lines. +#[derive(Debug, Clone, Copy, Eq, PartialEq)] +pub enum Newline { + /// Use the first newline sequence found in the input, falling back to `\n` if none is present. + Detect, + /// Always use a Unix newline (`\n`). + Lf, + /// Always use a Windows newline (`\r\n`). + CrLf, +} + +/// Options controlling [`normalize()`]. +/// +/// The defaults are intentionally conservative: they tidy the common sources of noise (stray +/// indentation, spacing around `=`, trailing whitespace, missing final newline) while leaving +/// blank lines and the substance of the file untouched. +/// +/// Note that trailing whitespace at the end of a line is always removed - it is never significant +/// in git-config syntax - so there is no option to retain it. +#[derive(Debug, Clone, Eq, PartialEq)] +pub struct Options { + /// How to indent key/value (and comment) lines beneath a section header. + pub indentation: Indentation, + /// If `true`, place a single space on each side of the `=` separator (`a = b`); + /// if `false`, emit a bare `=` (`a=b`). + pub spaces_around_separator: bool, + /// Which newline sequence to emit between lines. + pub newline: Newline, + /// If `true`, ensure a non-empty file ends with exactly one newline. + pub ensure_trailing_newline: bool, + /// If `Some(n)`, cap runs of consecutive blank lines at `n`. `None` (the default) leaves + /// blank lines exactly as they are. + pub max_consecutive_blank_lines: Option, +} + +impl Default for Options { + fn default() -> Self { + Options { + indentation: Indentation::Spaces(2), + spaces_around_separator: true, + newline: Newline::Detect, + ensure_trailing_newline: true, + max_consecutive_blank_lines: None, + } + } +} + +/// Parse `input` as a single git-config file and return it with whitespace normalized per `options`. +/// +/// Includes are never resolved. Values, comments and section headers are preserved byte-for-byte; +/// only insignificant whitespace, newlines and the `=` separator are rewritten. +/// +/// # Errors +/// +/// Returns a [`parse::Error`] if `input` is not a syntactically valid git-config file. +pub fn normalize(input: &[u8], options: &Options) -> Result { + let events = parse::Events::from_bytes(input, None)?.into_vec(); + Ok(normalize_events(&events, options)) +} + +fn detect_newline(events: &[Event<'_>]) -> &'static [u8] { + for event in events { + if let Event::Newline(n) = event { + return if n.contains(&b'\r') { b"\r\n" } else { b"\n" }; + } + } + b"\n" +} + +fn normalize_events(events: &[Event<'_>], opts: &Options) -> BString { + let newline: &[u8] = match opts.newline { + Newline::Detect => detect_newline(events), + Newline::Lf => b"\n", + Newline::CrLf => b"\r\n", + }; + let indent: Vec = match opts.indentation { + Indentation::Tab => vec![b'\t'], + Indentation::Spaces(n) => vec![b' '; n], + Indentation::None => Vec::new(), + }; + + let mut out: Vec = Vec::with_capacity(events.len() * 8); + let mut in_section = false; + let mut line_has_content = false; + let mut i = 0; + + while i < events.len() { + match &events[i] { + // Standalone, insignificant whitespace is dropped; we synthesize whitespace + // deterministically around the structural events below. + Event::Whitespace(_) => { + i += 1; + } + Event::SectionHeader(_) => { + events[i].write_to(&mut out).expect("write to Vec is infallible"); + in_section = true; + line_has_content = true; + i += 1; + } + Event::SectionValueName(_) => { + if in_section && !line_has_content { + out.extend_from_slice(&indent); + } + events[i].write_to(&mut out).expect("write to Vec is infallible"); + line_has_content = true; + i += 1; + } + Event::KeyValueSeparator => { + if opts.spaces_around_separator { + out.extend_from_slice(b" = "); + } else { + out.push(b'='); + } + line_has_content = true; + i += 1; + } + Event::Value(_) | Event::ValueDone(_) => { + events[i].write_to(&mut out).expect("write to Vec is infallible"); + line_has_content = true; + i += 1; + } + // A line-continuation span: emit everything verbatim through the closing `ValueDone`, + // so whitespace that the parser folded into the continued value is never touched. + Event::ValueNotDone(_) => { + loop { + let is_done = matches!(events[i], Event::ValueDone(_)); + events[i].write_to(&mut out).expect("write to Vec is infallible"); + i += 1; + if is_done || i >= events.len() { + break; + } + } + line_has_content = true; + } + Event::Comment(_) => { + if line_has_content { + // Inline comment trailing a value/header: one space before the marker. + out.push(b' '); + } else if in_section { + out.extend_from_slice(&indent); + } + events[i].write_to(&mut out).expect("write to Vec is infallible"); + line_has_content = true; + i += 1; + } + Event::Newline(n) => { + let mut count = n.iter().filter(|&&b| b == b'\n').count(); + if let Some(max_blank) = opts.max_consecutive_blank_lines { + // `count` newlines produce `count - 1` blank lines. + count = count.min(max_blank + 1); + } + for _ in 0..count { + out.extend_from_slice(newline); + } + line_has_content = false; + i += 1; + } + } + } + + if opts.ensure_trailing_newline && !out.is_empty() { + while out.last() == Some(&b'\n') || out.last() == Some(&b'\r') { + out.pop(); + } + out.extend_from_slice(newline); + } + + out.into() +} diff --git a/gix-config/src/parse/mod.rs b/gix-config/src/parse/mod.rs index 50b1389c486..bef3904cc0e 100644 --- a/gix-config/src/parse/mod.rs +++ b/gix-config/src/parse/mod.rs @@ -24,6 +24,8 @@ pub use events_type::{Events, FrontMatterEvents}; mod comment; mod error; /// +pub mod format; +/// pub mod section; #[cfg(test)] diff --git a/gix-config/tests/config/parse/format.rs b/gix-config/tests/config/parse/format.rs new file mode 100644 index 00000000000..e55d80c3629 --- /dev/null +++ b/gix-config/tests/config/parse/format.rs @@ -0,0 +1,185 @@ +use gix_config::parse::{ + Events, + format::{self, Indentation, Newline, Options}, +}; + +fn norm(input: &str) -> String { + let out = format::normalize(input.as_bytes(), &Options::default()).expect("valid config"); + String::from_utf8(out.into()).expect("utf8") +} + +/// Collect (section, name, value) triples from a config's event stream so two configs can be +/// compared for *meaning* rather than bytes. +fn semantic_triples(input: &str) -> Vec<(String, String, String)> { + use gix_config::parse::Event; + let events = Events::from_str(input).expect("valid").into_vec(); + let mut out = Vec::new(); + let mut section = String::new(); + let mut pending_name: Option = None; + let mut value = String::new(); + for ev in &events { + match ev { + Event::SectionHeader(h) => section = h.to_bstring().to_string(), + Event::SectionValueName(_) => { + if let Some(name) = pending_name.take() { + out.push((section.clone(), name, std::mem::take(&mut value))); + } + pending_name = Some(ev.to_bstr_lossy().to_string()); + value.clear(); + } + Event::Value(_) | Event::ValueDone(_) | Event::ValueNotDone(_) => { + value.push_str(&ev.to_bstr_lossy().to_string()); + } + _ => {} + } + } + if let Some(name) = pending_name.take() { + out.push((section, name, value)); + } + out +} + +#[test] +fn default_policy_basic() { + // 4-space indent collapses to the 2-space default; trailing whitespace and tight `=` are fixed. + let input = "[core]\n editor=vim \n"; + assert_eq!(norm(input), "[core]\n editor = vim\n"); +} + +#[test] +fn meaning_is_preserved() { + for input in [ + "[core]\n editor=vim\n", + "[remote \"origin\"]\n\turl = https://example.com/x.git\n", + "[a]\nx=1\ny = 2\n[b]\nz=3\n", + "[user]\n\tname = A B ; trailing comment\n", + ] { + assert_eq!( + semantic_triples(&norm(input)), + semantic_triples(input), + "formatting must not change meaning for: {input:?}" + ); + } +} + +#[test] +fn line_continuation_value_is_untouched() { + // The continued line's leading whitespace is part of the value and must survive verbatim. + let input = "[alias]\nsave = \"!f() { \\\n git status; \\\n}; f\"\n"; + let out = norm(input); + assert_eq!( + semantic_triples(&out), + semantic_triples(input), + "continuation value bytes must be preserved" + ); +} + +#[test] +fn trailing_backslash_at_eof() { + let input = "[core]\na=hello\\"; + // Must parse and round-trip without panicking or corrupting the continuation. + let out = norm(input); + assert_eq!(semantic_triples(&out), semantic_triples(input)); +} + +#[test] +fn implicit_boolean_key_keeps_no_separator() { + let input = "[core]\n autocrlf\n"; + assert_eq!(norm(input), "[core]\n autocrlf\n"); +} + +#[test] +fn comments_are_preserved() { + let input = "; top comment\n[core]\n# inner\n\teditor = vim ; inline\n"; + let out = norm(input); + assert!(out.contains("; top comment")); + assert!(out.contains("# inner")); + assert!(out.contains("; inline")); +} + +#[test] +fn quoted_subsection_and_value_verbatim() { + let input = "[test \"sub \\\"x\\\"\"]\n\tpath = \"C:\\\\root\"\n"; + assert_eq!(semantic_triples(&norm(input)), semantic_triples(input)); +} + +#[test] +fn crlf_is_detected_and_normalized() { + let input = "[core]\r\n editor=vim\r\n"; + assert_eq!(norm(input), "[core]\r\n editor = vim\r\n"); +} + +#[test] +fn blank_lines_left_alone_by_default() { + let input = "[a]\nx = 1\n\n\n[b]\ny = 2\n"; + assert_eq!(norm(input), "[a]\n x = 1\n\n\n[b]\n y = 2\n"); +} + +#[test] +fn blank_lines_collapsed_when_requested() { + let opts = Options { + max_consecutive_blank_lines: Some(1), + ..Options::default() + }; + let out = format::normalize("[a]\nx = 1\n\n\n\n[b]\ny = 2\n".as_bytes(), &opts).unwrap(); + assert_eq!(String::from_utf8(out.into()).unwrap(), "[a]\n x = 1\n\n[b]\n y = 2\n"); +} + +#[test] +fn spaces_around_separator_can_be_disabled() { + let opts = Options { + spaces_around_separator: false, + indentation: Indentation::None, + ..Options::default() + }; + let out = format::normalize("[core]\n editor = vim\n".as_bytes(), &opts).unwrap(); + assert_eq!(String::from_utf8(out.into()).unwrap(), "[core]\neditor=vim\n"); +} + +#[test] +fn tab_indentation_option() { + let opts = Options { + indentation: Indentation::Tab, + ..Options::default() + }; + let out = format::normalize("[core]\n editor=vim\n".as_bytes(), &opts).unwrap(); + assert_eq!(String::from_utf8(out.into()).unwrap(), "[core]\n\teditor = vim\n"); +} + +#[test] +fn no_indentation_option() { + let opts = Options { + indentation: Indentation::None, + ..Options::default() + }; + let out = format::normalize("[core]\n editor=vim\n".as_bytes(), &opts).unwrap(); + assert_eq!(String::from_utf8(out.into()).unwrap(), "[core]\neditor = vim\n"); +} + +#[test] +fn force_lf_newline() { + let opts = Options { + newline: Newline::Lf, + ..Options::default() + }; + let out = format::normalize("[core]\r\n editor = vim\r\n".as_bytes(), &opts).unwrap(); + assert_eq!(String::from_utf8(out.into()).unwrap(), "[core]\n editor = vim\n"); +} + +#[test] +fn force_crlf_newline() { + let opts = Options { + newline: Newline::CrLf, + ..Options::default() + }; + let out = format::normalize("[core]\n editor = vim\n".as_bytes(), &opts).unwrap(); + assert_eq!(String::from_utf8(out.into()).unwrap(), "[core]\r\n editor = vim\r\n"); +} + +#[test] +fn idempotent() { + let input = "[core]\n editor=vim\n[remote \"o\"]\nurl = x\n"; + let once = norm(input); + let twice = norm(&once); + assert_eq!(once, twice); +} diff --git a/gix-config/tests/config/parse/mod.rs b/gix-config/tests/config/parse/mod.rs index e81144b7970..229f9aacc13 100644 --- a/gix-config/tests/config/parse/mod.rs +++ b/gix-config/tests/config/parse/mod.rs @@ -3,6 +3,7 @@ use std::borrow::Cow; use gix_config::parse::{Event, Events, Section}; mod error; +mod format; mod from_bytes; mod section; From 6ec6c8b133279eae87115936264e496495c6138c Mon Sep 17 00:00:00 2001 From: Amey Pawar <138877912+ameyypawar@users.noreply.github.com> Date: Wed, 1 Jul 2026 19:14:24 +0530 Subject: [PATCH 2/3] gitoxide-core: add config::fmt to format a config file Reads the given file (or the repository-local configuration when none is given), normalizes its whitespace via `gix::config::parse::format`, and writes the result to stdout, an output file, or back in place. --- gitoxide-core/src/repository/config.rs | 27 +++++++++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/gitoxide-core/src/repository/config.rs b/gitoxide-core/src/repository/config.rs index 8f32c87b837..3eceaa35705 100644 --- a/gitoxide-core/src/repository/config.rs +++ b/gitoxide-core/src/repository/config.rs @@ -1,4 +1,4 @@ -use anyhow::{Result, bail}; +use anyhow::{Context, Result, bail}; use gix::{bstr::BString, config::AsKey}; use crate::OutputFormat; @@ -48,6 +48,31 @@ pub fn list( Ok(()) } +/// Format the git configuration file at `in_file`, or the repository-local configuration if `in_file` +/// is `None`, writing the result back in place, to `out_file`, or to `out` (stdout) respectively. +pub fn fmt( + repo: gix::Repository, + in_file: Option, + out_file: Option, + in_place: bool, + mut out: impl std::io::Write, +) -> Result<()> { + if in_place && out_file.is_some() { + bail!("Cannot combine --in-place with an explicit output file"); + } + let source = in_file.unwrap_or_else(|| repo.common_dir().join("config")); + let input = + std::fs::read(&source).with_context(|| format!("Could not read configuration file at '{}'", source.display()))?; + let formatted = gix::config::parse::format::normalize(&input, &Default::default())?; + let destination = if in_place { Some(source) } else { out_file }; + match destination { + Some(path) => std::fs::write(&path, &formatted) + .with_context(|| format!("Could not write formatted configuration to '{}'", path.display()))?, + None => out.write_all(&formatted)?, + } + Ok(()) +} + struct Filter { name: String, subsection: Option, From d0557bd340cb76bfb2824b3c1c5243ca771f8ff7 Mon Sep 17 00:00:00 2001 From: Amey Pawar Date: Wed, 1 Jul 2026 19:36:00 +0530 Subject: [PATCH 3/3] gix: add the `config fmt` subcommand Expose the gix-config whitespace formatter as `gix config fmt [--in-place] [in-file] [out-file]`: with no in-file it formats the repository-local configuration, and with no out-file it writes to stdout. The repository is only opened when the repository-local configuration is needed, so formatting an explicit file works outside a repository too. --- gitoxide-core/src/repository/config.rs | 14 +++++-- src/plumbing/main.rs | 58 ++++++++++++++++++-------- src/plumbing/options/mod.rs | 22 ++++++++++ 3 files changed, 72 insertions(+), 22 deletions(-) diff --git a/gitoxide-core/src/repository/config.rs b/gitoxide-core/src/repository/config.rs index 3eceaa35705..789ab5e695f 100644 --- a/gitoxide-core/src/repository/config.rs +++ b/gitoxide-core/src/repository/config.rs @@ -51,7 +51,7 @@ pub fn list( /// Format the git configuration file at `in_file`, or the repository-local configuration if `in_file` /// is `None`, writing the result back in place, to `out_file`, or to `out` (stdout) respectively. pub fn fmt( - repo: gix::Repository, + repo: Option, in_file: Option, out_file: Option, in_place: bool, @@ -60,9 +60,15 @@ pub fn fmt( if in_place && out_file.is_some() { bail!("Cannot combine --in-place with an explicit output file"); } - let source = in_file.unwrap_or_else(|| repo.common_dir().join("config")); - let input = - std::fs::read(&source).with_context(|| format!("Could not read configuration file at '{}'", source.display()))?; + let source = match in_file { + Some(path) => path, + None => repo + .context("Formatting the repository-local configuration requires being in a repository")? + .common_dir() + .join("config"), + }; + let input = std::fs::read(&source) + .with_context(|| format!("Could not read configuration file at '{}'", source.display()))?; let formatted = gix::config::parse::format::normalize(&input, &Default::default())?; let destination = if in_place { Some(source) } else { out_file }; match destination { diff --git a/src/plumbing/main.rs b/src/plumbing/main.rs index d9ef65d66dd..1b055583b76 100644 --- a/src/plumbing/main.rs +++ b/src/plumbing/main.rs @@ -768,24 +768,46 @@ pub fn main() -> Result<()> { } } } - Subcommands::Config(config::Platform { filter }) => prepare_and_run( - "config-list", - trace, - verbose, - progress, - progress_keep_open, - None, - move |_progress, out, _err| { - core::repository::config::list( - repository(Mode::LenientWithGitInstallConfig)?, - filter, - config, - format, - out, - ) - }, - ) - .map(|_| ()), + Subcommands::Config(config::Platform { filter, cmd }) => match cmd { + Some(config::Subcommands::Fmt { + in_place, + in_file, + out_file, + }) => prepare_and_run( + "config-fmt", + trace, + verbose, + progress, + progress_keep_open, + None, + move |_progress, out, _err| { + let repo = in_file + .is_none() + .then(|| repository(Mode::LenientWithGitInstallConfig)) + .transpose()?; + core::repository::config::fmt(repo, in_file, out_file, in_place, out) + }, + ) + .map(|_| ()), + None => prepare_and_run( + "config-list", + trace, + verbose, + progress, + progress_keep_open, + None, + move |_progress, out, _err| { + core::repository::config::list( + repository(Mode::LenientWithGitInstallConfig)?, + filter, + config, + format, + out, + ) + }, + ) + .map(|_| ()), + }, Subcommands::Free(subcommands) => match subcommands { free::Subcommands::Discover => prepare_and_run( "discover", diff --git a/src/plumbing/options/mod.rs b/src/plumbing/options/mod.rs index f21eff79194..8ce40ab139d 100644 --- a/src/plumbing/options/mod.rs +++ b/src/plumbing/options/mod.rs @@ -607,6 +607,8 @@ pub mod log { } pub mod config { + use std::path::PathBuf; + use gix::bstr::BString; /// Print all entries in a configuration file or access other sub-commands. @@ -619,6 +621,26 @@ pub mod config { /// and comparisons are case-insensitive. #[clap(value_parser = crate::shared::AsBString)] pub filter: Vec, + + /// Subcommands for working with configuration files. + #[clap(subcommand)] + pub cmd: Option, + } + + #[derive(Debug, clap::Subcommand)] + pub enum Subcommands { + /// Format a git configuration file, normalizing insignificant whitespace. + /// + /// Includes are never resolved; only whitespace, newlines and the `=` separator are rewritten. + Fmt { + /// Write the formatted result back to the input file instead of to standard output. + #[clap(long)] + in_place: bool, + /// The configuration file to format. If unset, the repository-local configuration is used. + in_file: Option, + /// Where to write the formatted result. If unset, it is written to standard output. + out_file: Option, + }, } }