Skip to content

Commit 6f4cfc7

Browse files
authored
Merge pull request #19 from bashandbone/copilot/sub-pr-12-again
Address PR review feedback: security, CLI ergonomics, git2 add_submodule, and correctness fixes
2 parents 879012c + e6de301 commit 6f4cfc7

10 files changed

Lines changed: 125 additions & 52 deletions

File tree

.roo/mcp.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@
2525
"args": [
2626
"-y",
2727
"@modelcontextprotocol/server-filesystem",
28-
"~/submod", "~/.cargo"
28+
"~/submod"
2929
],
3030
"alwaysAllow": [
3131
"read_file",

Cargo.toml

Lines changed: 0 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,6 @@ version = "0.2.0"
88
edition = "2024"
99
rust-version = "1.87"
1010
description = "A headache-free submodule management tool, built on top of gitoxide. Manage sparse checkouts, submodule updates, and adding/removing submodules with ease."
11-
license = "MIT" # Plain MIT license: plainlicense.org/licenses/permissive/mit/
1211
license-file = "LICENSE.md"
1312
repository = "https://github.com/bashandbone/submod"
1413
homepage = "https://github.com/bashandbone/submod"
@@ -20,15 +19,6 @@ keywords = [
2019
"gitoxide",
2120
"cli",
2221
"sparse-checkout",
23-
"git2",
24-
"gitmodules",
25-
"repository",
26-
"management",
27-
"command-line",
28-
"tool",
29-
"development",
30-
"utilities",
31-
"development-tools",
3222
]
3323
categories = ["command-line-utilities", "development-tools"]
3424

README.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -245,15 +245,15 @@ submod list --recursive
245245
Delete a submodule from configuration and filesystem:
246246
247247
```bash
248-
submod delete
248+
submod delete my-lib
249249
```
250250
251251
### `submod disable`
252252
253253
Disable a submodule without deleting files (sets `active = false`):
254254
255255
```bash
256-
submod disable
256+
submod disable my-lib
257257
```
258258
259259
### `submod nuke-it-from-orbit`

sample_config/submod.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,7 @@ ignore = "dirty" # Override default ignore setting for all submodules
5959
#
6060
# ## `shallow`
6161
#
62-
# If `true`, performs a shallow clone of the submodule, which means it only fetchest the most recent commit. Defaults to `false`. This is useful for large repositories where you only need the latest commit.
62+
# If `true`, performs a shallow clone of the submodule, which means it only fetches the most recent commit. Defaults to `false`. This is useful for large repositories where you only need the latest commit.
6363
#
6464

6565
# NAMES (the part between "[" and "]" below).

src/commands.rs

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -101,8 +101,8 @@ pub enum Commands {
101101
)]
102102
branch: Option<String>,
103103

104-
#[arg(short = 'i', long = "ignore", default_value = "unspecified", help = "What changes in the submodule git should ignore.")]
105-
ignore: Ignore,
104+
#[arg(short = 'i', long = "ignore", help = "What changes in the submodule git should ignore.")]
105+
ignore: Option<Ignore>,
106106

107107
#[arg(
108108
short = 'x',
@@ -112,11 +112,11 @@ pub enum Commands {
112112
)]
113113
sparse_paths: Option<Vec<String>>,
114114

115-
#[arg(short = 'f', long = "fetch", default_value = "unspecified", help = "Sets the recursive fetch behavior for the submodule (like, if we should fetch its submodules).")]
116-
fetch: FetchRecurse,
115+
#[arg(short = 'f', long = "fetch", help = "Sets the recursive fetch behavior for the submodule (like, if we should fetch its submodules).")]
116+
fetch: Option<FetchRecurse>,
117117

118-
#[arg(short = 'u', long = "update", default_value = "unspecified", help = "How git should update the submodule when you run `git submodule update`.")]
119-
update: Update,
118+
#[arg(short = 'u', long = "update", help = "How git should update the submodule when you run `git submodule update`.")]
119+
update: Option<Update>,
120120

121121
// TODO: Implement this arg
122122
#[arg(short = 's', long = "shallow", default_value = "false", action = clap::ArgAction::SetTrue, default_missing_value = "true", help = "If given, sets the submodule as a shallow clone. It will only fetch the last commit of the branch, not the full history.")]

src/git_ops/git2_ops.rs

Lines changed: 86 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -288,12 +288,92 @@ impl GitOperations for Git2Operations {
288288
Ok(())
289289
}
290290
fn add_submodule(&mut self, opts: &SubmoduleAddOptions) -> Result<()> {
291-
// git2 submodule cloning requires remote callbacks that are complex to configure.
292-
// Fall through to the CLI fallback which handles this reliably.
293-
Err(anyhow::anyhow!(
294-
"Unable to add submodule '{}' using the library API; it will be added using the Git CLI instead",
295-
opts.name
296-
))
291+
// 1. Create submodule entry in .gitmodules and index
292+
let mut sub = self
293+
.repo
294+
.submodule(&opts.url, opts.path.as_path(), true)
295+
.with_context(|| {
296+
format!(
297+
"Failed to create submodule entry for '{}' from '{}'",
298+
opts.name, opts.url
299+
)
300+
})?;
301+
302+
// 2. Configure clone options
303+
let mut update_opts = git2::SubmoduleUpdateOptions::new();
304+
let mut fetch_opts = git2::FetchOptions::new();
305+
if opts.shallow {
306+
fetch_opts.depth(1);
307+
}
308+
update_opts.fetch(fetch_opts);
309+
310+
// 3. Clone the submodule repository
311+
sub.clone(Some(&mut update_opts)).with_context(|| {
312+
format!(
313+
"Failed to clone submodule '{}' from '{}'",
314+
opts.name, opts.url
315+
)
316+
})?;
317+
318+
// 4. Add to index and finalize
319+
sub.add_to_index(true)
320+
.with_context(|| format!("Failed to add submodule '{}' to index", opts.name))?;
321+
sub.add_finalize()
322+
.with_context(|| format!("Failed to finalize submodule '{}'", opts.name))?;
323+
324+
// 5. Apply optional configuration via git config.
325+
// git2's submodule() keys the submodule by path; use the path as the config key.
326+
let path_str = opts.path.to_string_lossy();
327+
let mut config = self
328+
.repo
329+
.config()
330+
.with_context(|| "Failed to open git config")?;
331+
332+
// Set branch if specified
333+
if let Some(branch) = &opts.branch {
334+
let branch_key = format!("submodule.{}.branch", path_str);
335+
config
336+
.set_str(&branch_key, &branch.to_string())
337+
.with_context(|| format!("Failed to set branch for submodule '{}'", opts.name))?;
338+
}
339+
340+
// Set ignore rule if specified and not the sentinel Unspecified value
341+
if let Some(ignore) = &opts.ignore {
342+
if !matches!(ignore, SerializableIgnore::Unspecified) {
343+
let ignore_key = format!("submodule.{}.ignore", path_str);
344+
config
345+
.set_str(&ignore_key, &ignore.to_string())
346+
.with_context(|| {
347+
format!("Failed to set ignore for submodule '{}'", opts.name)
348+
})?;
349+
}
350+
}
351+
352+
// Set fetch recurse if specified and not the sentinel Unspecified value
353+
if let Some(fetch_recurse) = &opts.fetch_recurse {
354+
if !matches!(fetch_recurse, SerializableFetchRecurse::Unspecified) {
355+
let fetch_key = format!("submodule.{}.fetchRecurseSubmodules", path_str);
356+
config
357+
.set_str(&fetch_key, &fetch_recurse.to_string())
358+
.with_context(|| {
359+
format!("Failed to set fetchRecurse for submodule '{}'", opts.name)
360+
})?;
361+
}
362+
}
363+
364+
// Set update strategy if specified and not the sentinel Unspecified value
365+
if let Some(update) = &opts.update {
366+
if !matches!(update, SerializableUpdate::Unspecified) {
367+
let update_key = format!("submodule.{}.update", path_str);
368+
config
369+
.set_str(&update_key, &update.to_string())
370+
.with_context(|| {
371+
format!("Failed to set update for submodule '{}'", opts.name)
372+
})?;
373+
}
374+
}
375+
376+
Ok(())
297377
}
298378
fn init_submodule(&mut self, path: &str) -> Result<()> {
299379
let mut submodule = self

src/git_ops/mod.rs

Lines changed: 11 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -256,17 +256,20 @@ impl GitOperations for GitOpsManager {
256256
.or_else(|_| {
257257
let workdir = self.git2_ops.workdir()
258258
.ok_or_else(|| anyhow::anyhow!("Repository has no working directory"))?;
259-
let output = std::process::Command::new("git")
260-
.current_dir(workdir)
259+
let mut cmd = std::process::Command::new("git");
260+
cmd.current_dir(workdir)
261261
.arg("submodule")
262262
.arg("add")
263263
.arg("--name")
264-
.arg(&opts.name)
265-
.arg("--")
266-
.arg(&opts.url)
267-
.arg(&opts.path)
268-
.output()
269-
.context("Failed to run git submodule add")?;
264+
.arg(&opts.name);
265+
if let Some(branch) = &opts.branch {
266+
cmd.arg("--branch").arg(branch.to_string());
267+
}
268+
if opts.shallow {
269+
cmd.arg("--depth").arg("1");
270+
}
271+
cmd.arg("--").arg(&opts.url).arg(&opts.path);
272+
let output = cmd.output().context("Failed to run git submodule add")?;
270273
if output.status.success() {
271274
Ok(())
272275
} else {

src/main.rs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -81,9 +81,9 @@ fn main() -> Result<()> {
8181
set_url,
8282
sparse_paths_vec,
8383
Some(set_branch),
84-
Some(ignore),
85-
Some(fetch),
86-
Some(update),
84+
ignore,
85+
fetch,
86+
update,
8787
Some(shallow),
8888
no_init,
8989
)

src/options.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,7 @@ pub enum SerializableIgnore {
125125
None,
126126
/// Used as a sentinel value internally; do not use in a submod.toml or submod CLI command.
127127
#[serde(skip)]
128+
#[value(skip)]
128129
Unspecified,
129130
}
130131

@@ -279,6 +280,7 @@ pub enum SerializableFetchRecurse {
279280
Never,
280281
/// Used as a sentinel value internally; do not use in a submod.toml or submod CLI command.
281282
#[serde(skip)]
283+
#[value(skip)]
282284
Unspecified,
283285
}
284286

@@ -557,6 +559,7 @@ pub enum SerializableUpdate {
557559
None,
558560
/// Used as a sentinel value internally; do not use in a submod.toml or submod CLI command.
559561
#[serde(skip)]
562+
#[value(skip)]
560563
Unspecified,
561564
}
562565

src/utilities.rs

Lines changed: 12 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,10 @@
33
// SPDX-License-Identifier: LicenseRef-PlainMIT OR MIT
44
//! Utility functions for working with `Gitoxide` APIs commonly used across the codebase.
55
6-
use anyhow::Ok;
6+
use anyhow::Result;
77
use git2::Repository as Git2Repository;
88
use gix::open::Options;
99
use std::path::PathBuf;
10-
use std::result::Result;
1110

1211
/// Get the current repository using git2, with an optional provided repository. If no repository is provided, it will attempt to discover one in the current directory.
1312
pub(crate) fn get_current_git2_repository(
@@ -164,20 +163,18 @@ pub(crate) fn name_from_url(url: &str) -> Result<String, anyhow::Error> {
164163
/// Convert an `OsString` to a `String`, extracting the name from the path
165164
pub(crate) fn name_from_osstring(os_string: std::ffi::OsString) -> Result<String, anyhow::Error> {
166165
osstring_to_string(os_string).and_then(|s| {
167-
if s.is_empty() {
168-
if s.contains('\0') {
169-
Err(anyhow::anyhow!("Name cannot contain null bytes"))
170-
} else {
171-
Ok(s)
172-
}
173-
} else {
174-
let sep = std::path::MAIN_SEPARATOR.to_string();
175-
s.trim()
176-
.split(&sep)
177-
.last()
178-
.map(|name| name.to_string())
179-
.ok_or_else(|| anyhow::anyhow!("Failed to extract name from OsString"))
166+
if s.contains('\0') {
167+
return Err(anyhow::anyhow!("Name cannot contain null bytes"));
168+
}
169+
if s.trim().is_empty() {
170+
return Err(anyhow::anyhow!("Name cannot be empty or whitespace-only"));
180171
}
172+
let sep = std::path::MAIN_SEPARATOR.to_string();
173+
s.trim()
174+
.split(&sep)
175+
.last()
176+
.map(|name| name.to_string())
177+
.ok_or_else(|| anyhow::anyhow!("Failed to extract name from OsString"))
181178
})
182179
}
183180

0 commit comments

Comments
 (0)