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
36 changes: 32 additions & 4 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,11 @@ jobs:
test:
name: Test Suite
runs-on: ubuntu-latest
# beta/nightly are informational: a toolchain regression upstream should not
# gate merges, but we still want to see it.
continue-on-error: ${{ matrix.rust != 'stable' }}
strategy:
fail-fast: false
matrix:
rust:
- stable
Expand Down Expand Up @@ -52,6 +56,20 @@ jobs:
path: target
key: ${{ runner.os }}-cargo-build-target-${{ hashFiles('**/Cargo.lock') }}-${{ matrix.rust }}

- name: Install cargo-nextest
uses: taiki-e/install-action@v2
with:
tool: cargo-nextest

# The integration harness shells out to git; give it a committer identity.
- name: Setup git
run: |
git config --global user.email "ci@example.com"
git config --global user.name "CI"

- name: Run tests
run: cargo nextest run --all-features --no-fail-fast

lint:
name: lint
runs-on: ubuntu-latest
Expand Down Expand Up @@ -80,12 +98,20 @@ jobs:
pkl = "latest"
typos = { version = "latest", depends = ["rust"] }

- name: Run hk ci workflow
# mise installs the toolchain without optional components, so cargo_fmt and
# cargo_clippy have no binary to call.
- name: Add rustfmt and clippy components
run: rustup component add rustfmt clippy

# --all: hk defaults to staged/changed files, which on a fresh CI checkout
# leaves most steps with nothing to do. --skip-step cargo_test: the test
# job above owns running the suite. --no-fail-fast: report every lint
# problem in one run instead of aborting the rest on the first failure.
- name: Run hk check
env:
HK_MISE: 1
MISE_YES: 1
run: |
hk check || cd ../ && hk check
run: hk check --all --skip-step cargo_test --no-fail-fast

security_audit:
name: Security Audit
Expand All @@ -98,7 +124,9 @@ jobs:
- uses: rustsec/audit-check@v2
with:
token: ${{ secrets.GITHUB_TOKEN || github.token }}
ignore: RUSTSEC-2024-0364,RUSTSEC-2024-0436
# Keep in sync with the `ignore` list in deny.toml, which documents why
# each of these is accepted.
ignore: RUSTSEC-2024-0364,RUSTSEC-2024-0436,RUSTSEC-2026-0044,RUSTSEC-2026-0048

coverage:
name: Code Coverage
Expand Down
10 changes: 5 additions & 5 deletions Cargo.lock

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

2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -319,7 +319,7 @@ submod completeme bash > ~/.bash_completion.d/submod

```zsh "zsh"
# zsh has an fpath array with possible function directories. You can
# put your competions in any of these; we use the first one here:
# put your completions in any of these; we use the first one here:
ZSH_DEFAULT="${XDG_DATA_HOME:-~/.local/share}/zsh/site-functions"
ZFUNCDIR=""${fpath[1]:-$ZSH_DEFAULT}"
mkdir -p "$ZFUNCDIR"
Expand Down
5 changes: 3 additions & 2 deletions _typos.toml
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ locale = "en-us"
check-file = true
check-filename = true
extend-ignore-re = [
"(?s)(#|//)\\s*spellchecker:off.*?\\n\\s*(#|//)\\s*spellchecker:on"
"(?s)(#|//|<!--)\\s*spellchecker:off.*?\\n\\s*(#|//|<!--)\\s*spellchecker:on"
]
extend-ignore-identifiers-re = [
"iif",
Expand All @@ -30,5 +30,6 @@ extend-exclude = [
".venv/",
".git/",
"target/",
"docs/"
"docs/",
"CHANGELOG.md",
]
8 changes: 8 additions & 0 deletions deny.toml
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,14 @@ feature-depth = 1
ignore = [
"RUSTSEC-2024-0364", # gitoxide-core does not neutralize special characters for terminals. No patched version available.
"RUSTSEC-2024-0436", # paste is unmaintained; it is a transitive dependency via rustls-ffi -> macro_rules_attribute and has no patched version or security impact.
# aws-lc-sys 0.38.0 X.509 issues, both patched in >=0.39.0, but we cannot reach it:
# aws-lc-rs pins aws-lc-sys exactly, and it arrives transitively via
# gix[blocking-http-transport-curl-rustls] -> curl-sys -> rustls-ffi -> rustls -> aws-lc-rs.
# submod does not enable CRL checking and does not do its own certificate validation;
# it fetches from standard WebPKI hosts, whose certs carry dNSName SANs (the case both
# advisories call out as unaffected). Revisit when aws-lc-rs ships a bump.
"RUSTSEC-2026-0044", # AWS-LC X.509 name constraints bypass via wildcard/Unicode CN.
"RUSTSEC-2026-0048", # AWS-LC CRL distribution point scope check logic error.
# "RUSTSEC-0000-0000",
# { id = "RUSTSEC-0000-0000", reason = "you can specify a reason the advisory is ignored" },
# "a-crate-that-is-yanked@0.1.1", # you can also ignore yanked crate versions if you wish
Expand Down
30 changes: 18 additions & 12 deletions hk.pkl
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,20 @@
* SPDX-License-Identifier: LicenseRef-PlainMIT OR MIT
*/

amends "package://github.com/jdx/hk/releases/download/v1.2.0/hk@1.2.0#/Config.pkl"
import "package://github.com/jdx/hk/releases/download/v1.2.0/hk@1.2.0#/Builtins.pkl"
amends "package://github.com/jdx/hk/releases/download/v1.48.0/hk@1.48.0#/Config.pkl"
import "package://github.com/jdx/hk/releases/download/v1.48.0/hk@1.48.0#/Builtins.pkl"

local linters = new Mapping<String, Step> {

["cargo_deny"] = new Step {
workspace_indicator = "Cargo.toml"
glob = "Cargo.lock"
check = "cargo deny --all-features --manifest-path {{ workspace_indicator }} -f json -L warn check --audit-compatible-output --exclude-dev --hide-inclusion-graph | jq -e '.[].vulnerabilities | length == 0' || exit 1"
// Gate on vulnerabilities only, letting cargo-deny's own exit code decide.
// (The previous `-f json ... | jq -e '.[].vulnerabilities | length == 0'`
// pipeline no longer parses: with `-f json` cargo-deny streams one object
// per diagnostic, not an array, so jq failed with exit 5 on every run.)
// --exclude-dev is a top-level flag; it moved off the `check` subcommand.
check = "cargo deny --all-features --manifest-path {{ workspace_indicator }} --exclude-dev -L warn check advisories --hide-inclusion-graph -A unsound -A unmaintained -A yanked -A notice"
}
["cargo_fmt"] = Builtins.cargo_fmt
["cargo_clippy"] = Builtins.cargo_clippy
Expand All @@ -29,9 +34,13 @@ local linters = new Mapping<String, Step> {
// Spellchecker
["typos"] = new Step {
workspace_indicator = "_typos.toml"
glob = List( "*README", "*.{.login,astro,bash,bash_logout,bashrc,browserlistrc,conf,config,csh,css,cts,fish,gitattributes,gitmodules,html,htmx,ini,j2,jinja,jinja2,json,json5,jsonc,jsonl,ksh,md,mdown,mdtext,mdtxt,mdwn,mdx,mk,mkd,mts,nix,nu,pkl,profile,quokka,sass,scss,sh,sh,shellcheckrc,sql,sqlite,stylelintrc,svelte,tcsh,toml,ts,tsx,txt,yaml,yml,zlogin,zlogout,zprofile,zsh,zshenv,zshrc}", "*Dockerfile*", "*Makefile*", "*makefile*", "CHANGELOG*", "CODE_OF_CONDUCT*", "CONTRIBUTING*", "HACKING*", "LICENSE", "README*", "SECURITY*", "UNLICENSE")
check = "typos -j 8 --config {{ workspace_indicator }} {{ files }}"
fix = "typos --write-changes --config {{ workspace_indicator }} {{ files }}"
glob = List( "*README", "*.{.login,astro,bash,bash_logout,bashrc,browserlistrc,conf,config,csh,css,cts,fish,gitattributes,gitmodules,html,htmx,ini,j2,jinja,jinja2,json,json5,jsonc,jsonl,ksh,md,mdown,mdtext,mdtxt,mdwn,mdx,mk,mkd,mts,nix,nu,pkl,profile,quokka,sass,scss,sh,sh,shellcheckrc,sql,sqlite,stylelintrc,svelte,tcsh,toml,ts,tsx,txt,yaml,yml,zlogin,zlogout,zprofile,zsh,zshenv,zshrc}", "*Dockerfile*", "*Makefile*", "*makefile*", "CODE_OF_CONDUCT*", "CONTRIBUTING*", "HACKING*", "LICENSE", "README*", "SECURITY*", "UNLICENSE")
// CHANGELOG.md is generated by git-cliff from commit subjects; abbreviated
// commit hashes trip the spellchecker. _typos.toml also excludes it, but that
// only applies when typos walks a directory — hk passes explicit file paths.
exclude = "CHANGELOG.md"
check = "typos -j 8 {{ files }}"
fix = "typos --write-changes {{ files }}"
}


Expand Down Expand Up @@ -68,12 +77,9 @@ hooks {
steps = linters
}
["test"] {
steps = linters
.toMap()
.filter(
(name, _) -> name == "cargo_test")
.toMapping()

steps = new Mapping<String, Step> {
["cargo_test"] = linters["cargo_test"]
}
}

["ci"] {
Expand Down
2 changes: 1 addition & 1 deletion mise.toml
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ cargo-binstall = "latest"
"cargo:cargo-nextest" = "latest" # For running tests in parallel.
"cargo:cargo-smart-release" = "latest" # For automating releases.
"cargo:cargo-watch" = "latest" # For watching files and rerunning commands.
hk = "latest" # Handles git hooks, like pre-commit.
hk = "1.48" # Handles git hooks, like pre-commit. Pinned to match CI; hk.pkl targets the 1.48 schema.
jq = "latest"
pkl = "latest" # pkl for `hk`, which handles git hooks
rust = "1.96"
Expand Down
6 changes: 3 additions & 3 deletions src/commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -125,7 +125,7 @@ pub enum Commands {
num_args = 0..=1,
value_parser = clap::value_parser!(bool),
default_missing_value = "true",
help = "Opt out of submod's deny-all-by-default sparse-checkout model and use git's built-in behaviour instead. When set, the `!/*` prefix is NOT prepended automatically."
help = "Opt out of submod's deny-all-by-default sparse-checkout model and use git's built-in behavior instead. When set, the `!/*` prefix is NOT prepended automatically."
)]
use_git_default_sparse_checkout: Option<bool>,

Expand Down Expand Up @@ -180,7 +180,7 @@ pub enum Commands {
num_args = 0..=1,
value_parser = clap::value_parser!(bool),
default_missing_value = "true",
help = "Opt out of submod's deny-all-by-default sparse-checkout model and use git's built-in behaviour instead."
help = "Opt out of submod's deny-all-by-default sparse-checkout model and use git's built-in behavior instead."
)]
use_git_default_sparse_checkout: Option<bool>,

Expand Down Expand Up @@ -248,7 +248,7 @@ pub enum Commands {
num_args = 0..=1,
value_parser = clap::value_parser!(bool),
default_missing_value = "true",
help = "Set the global default for sparse-checkout mode. When true, all submodules use git's built-in behaviour instead of submod's deny-all-by-default model (unless overridden per-submodule)."
help = "Set the global default for sparse-checkout mode. When true, all submodules use git's built-in behavior instead of submod's deny-all-by-default model (unless overridden per-submodule)."
)]
use_git_default_sparse_checkout: Option<bool>,
},
Expand Down
4 changes: 2 additions & 2 deletions src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -174,7 +174,7 @@ pub struct SubmoduleDefaults {
pub fetch_recurse: Option<SerializableFetchRecurse>,
/// [`Update`][SerializableUpdate] setting for submodules
pub update: Option<SerializableUpdate>,
/// When `true`, use git's built-in sparse-checkout behaviour (no `!/*` prefix is
/// When `true`, use git's built-in sparse-checkout behavior (no `!/*` prefix is
/// prepended). Defaults to `false`, which uses submod's deny-all-by-default model.
/// Individual submodules can override this per-entry.
#[serde(default, skip_serializing_if = "Option::is_none")]
Expand Down Expand Up @@ -471,7 +471,7 @@ pub struct SubmoduleEntry {
/// Sparse checkout paths for this submodule (optional)
#[serde(skip_serializing_if = "Option::is_none")]
pub sparse_paths: Option<Vec<String>>,
/// When `true`, use git's built-in sparse-checkout behaviour instead of submod's
/// When `true`, use git's built-in sparse-checkout behavior instead of submod's
/// deny-all-by-default model. Overrides the global `[defaults]` setting.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub use_git_default_sparse_checkout: Option<bool>,
Expand Down
22 changes: 13 additions & 9 deletions src/git_manager.rs
Original file line number Diff line number Diff line change
Expand Up @@ -320,8 +320,7 @@ impl GitManager {
// Check if submodule has its own submodules
let has_submodules = submodule_repo
.submodules()
.map(|subs| subs.is_some_and(|mut iter| iter.next().is_some()))
.unwrap_or(false);
.is_ok_and(|subs| subs.is_some_and(|mut iter| iter.next().is_some()));

Ok(SubmoduleStatus {
path: submodule_path.to_string(),
Expand Down Expand Up @@ -406,8 +405,13 @@ impl GitManager {
no_init: bool,
use_git_default_sparse_checkout: Option<bool>,
) -> Result<(), SubmoduleError> {
let repo_root = self.git_ops.workdir().ok_or(SubmoduleError::RepositoryError)?;
if let Err(e) = crate::utilities::validate_submodule_path(repo_root, std::path::Path::new(&path)) {
let repo_root = self
.git_ops
.workdir()
.ok_or(SubmoduleError::RepositoryError)?;
if let Err(e) =
crate::utilities::validate_submodule_path(repo_root, std::path::Path::new(&path))
{
return Err(SubmoduleError::InvalidPath(e.to_string()));
}

Expand Down Expand Up @@ -531,7 +535,7 @@ impl GitManager {
///
/// By default (`use_git_default = false`) the deny-all-by-default model is applied:
/// `!/*` is prepended so only the explicitly listed `patterns` are checked out, and a
/// one-time informational message is printed to help users understand the behaviour and
/// one-time informational message is printed to help users understand the behavior and
/// opt out if needed.
///
/// When `use_git_default = true` the patterns are written as-is, matching git's own
Expand All @@ -552,7 +556,7 @@ impl GitManager {
eprintln!(
"ℹ️ submod uses a deny-all-by-default sparse-checkout model: `!/*` is \
automatically prepended so only the paths you list are checked out.\n\
To use git's default behaviour instead, set \
To use git's default behavior instead, set \
`use_git_default_sparse_checkout = true` in your submod.toml (globally \
under `[defaults]` or per submodule) or pass \
`--use-git-default-sparse-checkout`."
Expand Down Expand Up @@ -589,7 +593,7 @@ impl GitManager {
///
/// This implements the "modified cone pattern" approach: all paths are denied by
/// default and only the explicitly listed patterns are checked out. This makes the
/// intent clear and avoids surprises from git's default include-everything behaviour.
/// intent clear and avoids surprises from git's default include-everything behavior.
///
/// Blank entries (empty or whitespace-only strings) are stripped before processing.
/// If no non-blank include patterns remain after normalization, an empty list is
Expand Down Expand Up @@ -1477,7 +1481,7 @@ impl GitManager {
entries
.submodule_iter()
.find(|(_, e)| e.path.as_deref() == Some(path.as_str()))
.map(|(n, _)| n.to_string())
.map(|(n, _)| n.clone())
};

if let Some(gm_name) = gitmodules_name {
Expand Down Expand Up @@ -1876,7 +1880,7 @@ mod tests {
use tempfile::tempdir;

// Helper to create a `GitManager` for tests that need to call methods such as
// `check_sparse_checkout_status`. It initialises a real git repository in `repo_dir`
// `check_sparse_checkout_status`. It initializes a real git repository in `repo_dir`
// via `git2` so that `GitOpsManager` can open it without depending on the caller's
// working directory being inside a git repo.
fn create_test_manager(repo_dir: &Path, config_path: PathBuf) -> GitManager {
Expand Down
14 changes: 11 additions & 3 deletions src/git_ops/git2_ops.rs
Original file line number Diff line number Diff line change
Expand Up @@ -208,7 +208,9 @@ impl GitOperations for Git2Operations {
))
}
fn write_gitmodules(&mut self, config: &SubmoduleEntries) -> Result<()> {
let workdir = self.repo.workdir()
let workdir = self
.repo
.workdir()
.ok_or_else(|| anyhow::anyhow!("Repository has no working directory"))?;
let gitmodules_path = workdir.join(".gitmodules");

Expand Down Expand Up @@ -239,10 +241,16 @@ impl GitOperations for Git2Operations {
gitmodules_config.set_str(&format!("{section}.ignore"), &ignore.to_gitmodules())?;
}
if let Some(fetch_recurse) = &entry.fetch_recurse {
gitmodules_config.set_str(&format!("{section}.fetchRecurseSubmodules"), &fetch_recurse.to_gitmodules())?;
gitmodules_config.set_str(
&format!("{section}.fetchRecurseSubmodules"),
&fetch_recurse.to_gitmodules(),
)?;
}
if let Some(active) = entry.active {
gitmodules_config.set_str(&format!("{section}.active"), if active { "true" } else { "false" })?;
gitmodules_config.set_str(
&format!("{section}.active"),
if active { "true" } else { "false" },
)?;
}

// 2. Sync to local repository config (.git/config) if the submodule exists in the repository
Expand Down
4 changes: 2 additions & 2 deletions src/git_ops/gix_ops.rs
Original file line number Diff line number Diff line change
Expand Up @@ -500,7 +500,7 @@ impl GitOperations for GixOperations {
let submodule_name = entries
.submodule_iter()
.find(|(_, entry)| entry.path.as_ref() == Some(&path.to_string()))
.map(|(name, _)| name.to_string())
.map(|(name, _)| name.clone())
.ok_or_else(|| anyhow::anyhow!("Submodule '{path}' not found in .gitmodules"))?;

// 3. Remove from .gitmodules
Expand Down Expand Up @@ -583,7 +583,7 @@ impl GitOperations for GixOperations {
let submodule_name = entries
.submodule_iter()
.find(|(_, entry)| entry.path.as_ref() == Some(&path.to_string()))
.map(|(name, _)| name.to_string())
.map(|(name, _)| name.clone())
.ok_or_else(|| anyhow::anyhow!("Submodule '{path}' not found in .gitmodules"))?;
self.clone().try_gix_operation_mut(|repo| {
// 1. Get the submodule directory
Expand Down
2 changes: 1 addition & 1 deletion src/git_ops/simple_gix.rs
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ pub fn setup_line_renderer_range(
prodash::render::line::Options {
level_filter: Some(levels),
frames_per_second: 6.0,
initial_delay: Some(std::time::Duration::from_millis(1000)),
initial_delay: Some(std::time::Duration::from_secs(1)),
timestamp: true,
throughput: true,
hide_cursor: true,
Expand Down
2 changes: 1 addition & 1 deletion src/options.rs
Original file line number Diff line number Diff line change
Expand Up @@ -462,7 +462,7 @@ impl GitmodulesConvert for SerializableBranch {
fn to_gitmodules(&self) -> String {
match self {
Self::CurrentInSuperproject => ".".to_string(),
Self::Name(name) => name.to_string(),
Self::Name(name) => name.clone(),
}
}

Expand Down
Loading
Loading