-
Notifications
You must be signed in to change notification settings - Fork 1
Expand file tree
/
Copy pathgit_manager.rs
More file actions
2041 lines (1830 loc) · 79.1 KB
/
git_manager.rs
File metadata and controls
2041 lines (1830 loc) · 79.1 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
// SPDX-FileCopyrightText: 2025 Adam Poulemanos <89049923+bashandbone@users.noreply.github.com>
//
// SPDX-License-Identifier: LicenseRef-PlainMIT OR MIT
# library, with fallbacks to `git2` and the Git CLI when needed. Supports sparse checkout and TOML-based configuration.
## Overview
- Loads submodule configuration from a TOML file.
- Adds, initializes, updates, resets, and checks submodules.
- Uses `gitoxide` APIs where possible for performance and reliability.
- Falls back to `git2` (if enabled) or the Git CLI for unsupported operations.
- Supports sparse checkout configuration per submodule.
## Key Types
- [`SubmoduleError`](src/git_manager.rs:14): Error type for submodule operations.
- [`SubmoduleStatus`](src/git_manager.rs:55): Reports the status of a submodule, including cleanliness, commit, remotes, and sparse checkout state.
- [`SparseStatus`](src/git_manager.rs:77): Describes the sparse checkout configuration state.
- [`GitManager`](src/git_manager.rs:94): Main struct for submodule management.
## Main Operations
- [`GitManager::add_submodule()`](src/git_manager.rs:207): Adds a new submodule, configuring sparse checkout if specified.
- [`GitManager::init_submodule()`](src/git_manager.rs:643): Initializes a submodule, adding it if missing.
- [`GitManager::update_submodule()`](src/git_manager.rs:544): Updates a submodule using the Git CLI.
- [`GitManager::reset_submodule()`](src/git_manager.rs:574): Resets a submodule (stash, hard reset, clean).
- [`GitManager::check_all_submodules()`](src/git_manager.rs:732): Checks the status of all configured submodules.
## Sparse Checkout Support
- Checks and configures sparse checkout for each submodule based on the TOML config.
- Uses a **deny-all-by-default** (modified cone pattern) model: the `!/*` pattern is
automatically prepended to the user-supplied patterns so that _only_ the explicitly
listed paths are checked out. Users simply list what they want to include; no
knowledge of git's pattern ordering rules is required.
## Error Handling
All operations return [`SubmoduleError`](src/git_manager.rs:14) for consistent error reporting.
## TODOs
- TODO: Implement submodule addition using gitoxide APIs when available ([`add_submodule_with_gix`](src/git_manager.rs:278)). Until then, we need to make git2 a required dependency.
## Usage
Use this module as the backend for CLI commands to manage submodules in a repository. See the project [README](README.md) for usage examples and configuration details.
"]
use crate::config::{Config, SubmoduleEntry};
use crate::git_ops::GitOperations;
use crate::git_ops::GitOpsManager;
use crate::options::{
SerializableBranch, SerializableFetchRecurse, SerializableIgnore, SerializableUpdate,
};
use std::fs;
use std::path::{Path, PathBuf};
/// The deny-all pattern prepended to sparse-checkout files in deny-all-by-default mode.
///
/// Placing `!/*` as the first line ensures that all paths are excluded by
/// default and only the explicitly listed include patterns are checked out
/// (the "modified cone pattern" model).
///
/// This pattern is **not** written when there are no include patterns (i.e., when the
/// caller passes an empty list or a list consisting entirely of blank strings), and it
/// is **not** written when the submodule opts out via `use_git_default_sparse_checkout`.
const SPARSE_DENY_ALL: &str = "!/*";
/// Custom error types for submodule operations
#[derive(Debug, thiserror::Error)]
pub enum SubmoduleError {
/// Error from gitoxide library operations
#[error("Gitoxide operation failed: {0}")]
#[allow(dead_code)]
GitoxideError(String),
/// Error from git2 library operations (when git2-support feature is enabled)
#[error("git2 operation failed: {0}")]
Git2Error(#[from] git2::Error),
/// Error from Git CLI operations
#[error("Git CLI operation failed: {0}")]
#[allow(dead_code)]
CliError(String),
/// Configuration-related error
#[error("Configuration error: {0}")]
#[allow(dead_code)]
ConfigError(String),
/// I/O operation error
#[error("IO error: {0}")]
IoError(#[from] std::io::Error),
/// Submodule not found in repository
#[error("Submodule {name} not found")]
SubmoduleNotFound {
/// Name of the missing submodule.
name: String,
},
/// Repository access or validation error
#[error("Repository not found or invalid")]
#[allow(dead_code)]
RepositoryError,
}
/// Status information for a submodule
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SubmoduleStatus {
/// Path to the submodule directory
#[allow(dead_code)]
pub path: String,
/// Whether the submodule working directory is clean
pub is_clean: bool,
/// Current commit hash of the submodule
pub current_commit: Option<String>,
/// Whether the submodule has remote repositories configured
pub has_remotes: bool,
/// Whether the submodule is initialized
#[allow(dead_code)]
pub is_initialized: bool,
/// Whether the submodule is active
#[allow(dead_code)]
pub is_active: bool,
/// Sparse checkout status for this submodule
pub sparse_status: SparseStatus,
/// Whether the submodule has its own submodules
pub has_submodules: bool,
}
/// Sparse checkout status
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SparseStatus {
/// Sparse checkout is not enabled for this submodule
NotEnabled,
/// Sparse checkout is enabled but not configured
NotConfigured,
/// Sparse checkout configuration matches expected paths
Correct,
/// Sparse checkout configuration doesn't match expected paths
Mismatch {
/// Expected sparse checkout paths
expected: Vec<String>,
/// Actual sparse checkout paths
actual: Vec<String>,
},
}
/// Main gitoxide-based submodule manager
pub struct GitManager {
/// The main git operations manager (gix-first, git2-fallback)
git_ops: GitOpsManager,
/// Configuration for submodules
config: Config,
/// Path to the configuration file
config_path: PathBuf,
/// Whether to print verbose output
verbose: bool,
}
impl GitManager {
/// Helper method to map git operations errors
fn map_git_ops_error(err: anyhow::Error) -> SubmoduleError {
SubmoduleError::ConfigError(format!("Git operation failed: {err}"))
}
/// Restore `update_toml_config` method
fn update_toml_config(
&mut self,
name: String,
mut entry: crate::config::SubmoduleEntry,
sparse_paths: Option<Vec<String>>,
) -> Result<(), SubmoduleError> {
if let Some(paths) = sparse_paths {
// Move the Vec into entry.sparse_paths to avoid cloning,
// then borrow it for add_checkout.
entry.sparse_paths = Some(paths);
if let Some(ref stored_paths) = entry.sparse_paths {
// Also populate sparse_checkouts so consumers using sparse_checkouts() see the paths
self.config
.submodules
.add_checkout(name.clone(), stored_paths, true);
}
}
// Normalize: convert Unspecified variants to None so they serialize cleanly
if matches!(entry.ignore, Some(SerializableIgnore::Unspecified)) {
entry.ignore = None;
}
if matches!(
entry.fetch_recurse,
Some(SerializableFetchRecurse::Unspecified)
) {
entry.fetch_recurse = None;
}
if matches!(entry.update, Some(SerializableUpdate::Unspecified)) {
entry.update = None;
}
self.config.add_submodule(name, entry);
self.save_config()
}
/// Save the current in-memory configuration to the config file
fn save_config(&self) -> Result<(), SubmoduleError> {
// Read existing TOML to preserve content (defaults, comments, existing entries)
let existing = if self.config_path.exists() {
std::fs::read_to_string(&self.config_path)
.map_err(|e| SubmoduleError::ConfigError(format!("Failed to read config: {e}")))?
} else {
String::new()
};
let mut output = existing.clone();
// Append any new submodule sections not already in the file
for (name, entry) in self.config.get_submodules() {
// Determine whether this name needs quoting (contains TOML-special characters).
// Simple names (alphanumeric, hyphens, underscores) can use the bare [name] form.
let needs_quoting = name
.chars()
.any(|c| !c.is_alphanumeric() && c != '-' && c != '_');
let escaped_name = name.replace('\\', "\\\\").replace('"', "\\\"");
let section_header = if needs_quoting {
format!("[\"{escaped_name}\"]")
} else {
format!("[{name}]")
};
// Check at line boundaries to avoid false positives from comments/values.
// Accept either quoted or unquoted form so existing files written before this
// change are recognised.
let already_present = existing.lines().any(|line| {
let trimmed = line.trim();
trimmed == section_header
|| trimmed == format!("[{name}]")
|| trimmed == format!("[\"{escaped_name}\"]")
});
if !already_present {
output.push('\n');
output.push_str(§ion_header);
output.push('\n');
if let Some(path) = &entry.path {
output.push_str(&format!(
"path = \"{}\"\n",
path.replace('\\', "\\\\").replace('"', "\\\"")
));
}
if let Some(url) = &entry.url {
output.push_str(&format!(
"url = \"{}\"\n",
url.replace('\\', "\\\\").replace('"', "\\\"")
));
}
if let Some(branch) = &entry.branch {
let val = branch.to_string();
if !val.is_empty() {
output.push_str(&format!(
"branch = \"{}\"\n",
val.replace('\\', "\\\\").replace('"', "\\\"")
));
}
}
if let Some(ignore) = &entry.ignore {
let val = ignore.to_string();
if !val.is_empty() {
output.push_str(&format!("ignore = \"{val}\"\n"));
}
}
if let Some(fetch_recurse) = &entry.fetch_recurse {
let val = fetch_recurse.to_string();
if !val.is_empty() {
output.push_str(&format!("fetch = \"{val}\"\n"));
}
}
if let Some(update) = &entry.update {
let val = update.to_string();
if !val.is_empty() {
output.push_str(&format!("update = \"{val}\"\n"));
}
}
if let Some(active) = entry.active {
output.push_str(&format!("active = {active}\n"));
}
if let Some(shallow) = entry.shallow
&& shallow {
output.push_str("shallow = true\n");
}
if let Some(sparse_paths) = &entry.sparse_paths
&& !sparse_paths.is_empty() {
let joined = sparse_paths
.iter()
.map(|p| {
format!("\"{}\"", p.replace('\\', "\\\\").replace('"', "\\\""))
})
.collect::<Vec<_>>()
.join(", ");
output.push_str(&format!("sparse_paths = [{joined}]\n"));
}
}
}
std::fs::write(&self.config_path, &output).map_err(|e| {
SubmoduleError::ConfigError(format!("Failed to write config file: {e}"))
})?;
Ok(())
}
/// Creates a new `GitManager` by loading configuration from the given path
/// with default (non-verbose) output.
///
/// # Arguments
///
/// * `config_path` - Path to the TOML configuration file.
///
/// # Errors
///
/// Returns `SubmoduleError::RepositoryError` if the repository cannot be discovered,
/// or `SubmoduleError::ConfigError` if the configuration fails to load.
#[allow(dead_code)]
pub fn new(config_path: PathBuf) -> Result<Self, SubmoduleError> {
Self::with_verbose(config_path, false)
}
/// Creates a new `GitManager` with the specified verbosity level.
pub fn with_verbose(config_path: PathBuf, verbose: bool) -> Result<Self, SubmoduleError> {
// Use GitOpsManager for repository detection and operations
let git_ops = GitOpsManager::new(Some(Path::new(".")), verbose)
.map_err(|_| SubmoduleError::RepositoryError)?;
let config = Config::default()
.load(&config_path, Config::default())
.map_err(|e| SubmoduleError::ConfigError(format!("Failed to load config: {e}")))?;
Ok(Self {
git_ops,
config,
config_path,
verbose,
})
}
/// Creates a `GitManager` pointed at an explicit repository path.
///
/// Used in tests to avoid depending on the caller's working directory
/// being a git repository.
#[cfg(test)]
fn with_repo_path(config_path: PathBuf, repo_path: &Path) -> Result<Self, SubmoduleError> {
let git_ops = GitOpsManager::new(Some(repo_path), false)
.map_err(|_| SubmoduleError::RepositoryError)?;
let config = Config::default()
.load(&config_path, Config::default())
.map_err(|e| SubmoduleError::ConfigError(format!("Failed to load config: {e}")))?;
Ok(Self {
git_ops,
config,
config_path,
verbose: false,
})
}
/// Check submodule repository status using gix APIs
pub fn check_submodule_repository_status(
&self,
submodule_path: &str,
name: &str,
) -> Result<SubmoduleStatus, SubmoduleError> {
// NOTE: This is a legacy direct gix usage for status; could be refactored to use GitOpsManager if needed.
let submodule_repo =
gix::open(submodule_path).map_err(|_| SubmoduleError::RepositoryError)?;
// GITOXIDE API: Use gix for what's available, fall back to CLI for complex status
// For now, use a simple approach - check if there are any uncommitted changes
let is_dirty = match submodule_repo.head() {
Ok(_head) => {
// Simple check - if we can get head, assume repository is clean
// This is a conservative approach until we can use the full status API
false
}
Err(_) => true,
};
// GITOXIDE API: Use reference APIs for current commit
let current_commit = match submodule_repo.head() {
Ok(head) => head.id().map(|id| id.to_string()),
Err(_) => None,
};
// GITOXIDE API: Use remote APIs to check if remotes exist
let has_remotes = !submodule_repo.remote_names().is_empty();
// For now, consider all submodules active if they exist in config
let is_active = self.config.submodules.contains_key(name);
// Check sparse checkout status
let sparse_status =
if let Some(sparse_checkouts) = self.config.submodules.sparse_checkouts() {
if let Some(expected_paths) = sparse_checkouts.get(name) {
self.check_sparse_checkout_status(submodule_path, expected_paths)?
} else {
SparseStatus::NotEnabled
}
} else {
SparseStatus::NotEnabled
};
// 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);
Ok(SubmoduleStatus {
path: submodule_path.to_string(),
is_clean: !is_dirty,
current_commit,
has_remotes,
is_initialized: true,
is_active,
sparse_status,
has_submodules,
})
}
/// Check whether the sparse-checkout configuration for a submodule matches
/// the expected paths.
///
/// Returns [`SparseStatus::Correct`] when every expected path is present in
/// the configured file. Extra patterns in the file that are not in
/// `expected_paths` are **not** treated as a mismatch; the check is a
/// subset test (all expected ⊆ configured). Returns [`SparseStatus::Mismatch`]
/// when at least one expected path is absent from the file.
pub fn check_sparse_checkout_status(
&self,
submodule_path: &str,
expected_paths: &[String],
) -> Result<SparseStatus, SubmoduleError> {
// Try to find the sparse-checkout file for the submodule
let git_dir = self.get_git_directory(submodule_path)?;
let sparse_checkout_file = git_dir.join("info").join("sparse-checkout");
if !sparse_checkout_file.exists() {
return Ok(SparseStatus::NotConfigured);
}
let content = fs::read_to_string(&sparse_checkout_file)?;
let configured_paths: Vec<String> = content
.lines()
.map(str::trim)
.filter(|line| !line.is_empty() && !line.starts_with('#'))
.map(std::string::ToString::to_string)
.collect();
// Filter the auto-managed deny-all prefix from both sides so that comparison
// reflects only the user-specified include patterns.
let configured_user: Vec<String> = configured_paths
.iter()
.filter(|p| p.as_str() != SPARSE_DENY_ALL)
.cloned()
.collect();
let expected_user: Vec<String> = expected_paths
.iter()
.filter(|p| p.as_str() != SPARSE_DENY_ALL)
.cloned()
.collect();
let matches = expected_user
.iter()
.all(|path| configured_user.contains(path));
if matches {
Ok(SparseStatus::Correct)
} else {
Ok(SparseStatus::Mismatch {
expected: expected_user,
actual: configured_user,
})
}
}
/// Add a submodule using the fallback chain: gitoxide -> git2 -> CLI
#[allow(clippy::too_many_arguments)]
pub fn add_submodule(
&mut self,
name: String,
path: String,
url: String,
sparse_paths: Option<Vec<String>>,
branch: Option<SerializableBranch>,
ignore: Option<SerializableIgnore>,
fetch_recurse: Option<SerializableFetchRecurse>,
update: Option<SerializableUpdate>,
shallow: Option<bool>,
no_init: bool,
use_git_default_sparse_checkout: Option<bool>,
) -> Result<(), SubmoduleError> {
if no_init {
self.update_toml_config(
name.clone(),
SubmoduleEntry {
path: Some(path.clone()),
url: Some(url),
branch,
ignore,
update,
fetch_recurse,
active: Some(!no_init),
shallow,
no_init: Some(no_init),
sparse_paths: None,
use_git_default_sparse_checkout,
},
sparse_paths,
)?;
// When requested, only update configuration without touching repository state.
return Ok(());
}
// Clean up any existing submodule state using git commands
self.cleanup_existing_submodule(&path)?;
let opts = crate::config::SubmoduleAddOptions {
name: name.clone(),
path: std::path::PathBuf::from(&path),
url: url.clone(),
branch: branch.clone(),
ignore,
update: update.clone(),
fetch_recurse,
shallow: shallow.unwrap_or(false),
no_init,
};
match self
.git_ops
.add_submodule(&opts)
.map_err(Self::map_git_ops_error)
{
Ok(()) => {
// Store the opt-out flag in config before configuring sparse checkout
// so that the helper can resolve it.
{
let entry = SubmoduleEntry {
path: Some(path.clone()),
url: Some(url.clone()),
branch: branch.clone(),
ignore,
update: update.clone(),
fetch_recurse,
active: Some(!no_init),
shallow,
no_init: Some(no_init),
sparse_paths: None,
use_git_default_sparse_checkout,
};
self.config.add_submodule(name.clone(), entry);
}
// Configure after successful submodule creation
self.configure_submodule_post_creation(&name, &path, sparse_paths.clone())?;
self.update_toml_config(
name.clone(),
SubmoduleEntry {
path: Some(path),
url: Some(url),
branch,
ignore,
update,
fetch_recurse,
active: Some(!no_init),
shallow,
no_init: Some(no_init),
sparse_paths: None, // stored separately via configure_submodule_post_creation
use_git_default_sparse_checkout,
},
sparse_paths,
)?;
println!("Added submodule {name}");
Ok(())
}
Err(e) => Err(e),
}
}
/// Clean up existing submodule state using git commands only
fn cleanup_existing_submodule(&mut self, path: &str) -> Result<(), SubmoduleError> {
// Best-effort cleanup of any existing submodule state
// These operations may fail if the submodule doesn't exist yet, which is fine,
// but other errors (permissions, corruption, etc.) should at least be visible.
if let Err(e) = self.git_ops.deinit_submodule(path, true) {
eprintln!("Warning: failed to deinit submodule at '{path}': {e:?}");
}
if let Err(e) = self.git_ops.delete_submodule(path) {
eprintln!("Warning: failed to delete submodule at '{path}': {e:?}");
}
Ok(())
}
/// Configure submodule for post-creation setup
fn configure_submodule_post_creation(
&mut self,
name: &str,
path: &str,
sparse_paths: Option<Vec<String>>,
) -> Result<(), SubmoduleError> {
// Only configure git-level sparse checkout if the submodule directory exists
// (it may not exist yet if --no-init was used)
let submodule_exists = std::path::Path::new(path).exists();
if submodule_exists
&& let Some(patterns) = sparse_paths {
let use_git_default = self.effective_use_git_default_sparse_checkout(name);
self.configure_sparse_checkout(path, &patterns, use_git_default)?;
}
Ok(())
}
/// Configure sparse checkout using basic file operations.
///
/// 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
/// opt out if needed.
///
/// When `use_git_default = true` the patterns are written as-is, matching git's own
/// sparse-checkout semantics.
pub fn configure_sparse_checkout(
&mut self,
submodule_path: &str,
patterns: &[String],
use_git_default: bool,
) -> Result<(), SubmoduleError> {
let effective_patterns = if use_git_default {
// Pass through unchanged — caller opts out of the deny-all model.
patterns.to_vec()
} else {
// Normalize to the deny-all-by-default model.
let normalized = Self::build_deny_all_sparse_patterns(patterns);
if !normalized.is_empty() {
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 \
`use_git_default_sparse_checkout = true` in your submod.toml (globally \
under `[defaults]` or per submodule) or pass \
`--use-git-default-sparse-checkout`."
);
}
normalized
};
self.git_ops
.enable_sparse_checkout(submodule_path)
.map_err(|e| {
SubmoduleError::GitoxideError(format!("Enable sparse checkout failed: {e}"))
})?;
self.git_ops
.set_sparse_patterns(submodule_path, &effective_patterns)
.map_err(|e| {
SubmoduleError::GitoxideError(format!("Set sparse patterns failed: {e}"))
})?;
self.git_ops
.apply_sparse_checkout(submodule_path)
.map_err(|e| {
SubmoduleError::GitoxideError(format!("Apply sparse checkout failed: {e}"))
})?;
println!("Configured sparse checkout");
Ok(())
}
/// Normalizes the input by stripping blank entries and removing any existing `!/*`
/// entries, then prepends a single `!/*` when at least one include pattern remains.
///
/// 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.
///
/// Blank entries (empty or whitespace-only strings) are stripped before processing.
/// If no non-blank include patterns remain after normalization, an empty list is
/// returned (no sparse-checkout file is written for an empty pattern list).
fn build_deny_all_sparse_patterns(patterns: &[String]) -> Vec<String> {
// Strip blank entries that can arrive from empty CLI values (e.g., --sparse-paths "").
// Also remove any existing deny-all markers so we can prepend a single canonical one.
let includes: Vec<String> = patterns
.iter()
.filter_map(|p| {
let trimmed = p.trim();
if trimmed.is_empty() || trimmed == SPARSE_DENY_ALL {
None
} else {
Some(p.clone())
}
})
.collect();
if includes.is_empty() {
Vec::new()
} else {
let mut result = Vec::with_capacity(includes.len() + 1);
result.push(SPARSE_DENY_ALL.to_string());
result.extend(includes);
result
}
}
/// Resolve the effective `use_git_default_sparse_checkout` setting for a submodule.
///
/// The per-submodule entry takes precedence over the global `[defaults]` setting.
/// When neither is set, `false` is returned (submod's deny-all-by-default model).
fn effective_use_git_default_sparse_checkout(&self, submodule_name: &str) -> bool {
let per_submodule = self
.config
.get_submodule(submodule_name)
.and_then(|e| e.use_git_default_sparse_checkout);
match per_submodule {
Some(v) => v,
None => self
.config
.defaults
.use_git_default_sparse_checkout
.unwrap_or(false),
}
}
/// Get the actual git directory path, handling gitlinks in submodules
fn get_git_directory(
&self,
submodule_path: &str,
) -> Result<std::path::PathBuf, SubmoduleError> {
let git_path = std::path::Path::new(submodule_path).join(".git");
if git_path.is_dir() {
// Regular git repository
Ok(git_path)
} else if git_path.is_file() {
// Gitlink - read the file to get the actual git directory
let content = fs::read_to_string(&git_path)?;
let git_dir_line = content
.lines()
.find(|line| line.starts_with("gitdir: "))
.ok_or_else(|| {
SubmoduleError::IoError(std::io::Error::new(
std::io::ErrorKind::InvalidData,
"Invalid gitlink file",
))
})?;
let git_dir_path = git_dir_line.strip_prefix("gitdir: ").unwrap().trim();
// Path might be relative to the submodule directory
let absolute_path = if std::path::Path::new(git_dir_path).is_absolute() {
std::path::PathBuf::from(git_dir_path)
} else {
std::path::Path::new(submodule_path).join(git_dir_path)
};
Ok(absolute_path)
} else {
// Use gix as fallback
if let Ok(repo) = gix::open(submodule_path) {
Ok(repo.git_dir().to_path_buf())
} else {
Err(SubmoduleError::RepositoryError)
}
}
}
// Removed: apply_sparse_checkout_cli is obsolete; sparse checkout is handled by GitOpsManager abstraction.
/// Update submodule using CLI fallback (gix remote operations are complex for this use case)
pub fn update_submodule(&mut self, name: &str) -> Result<(), SubmoduleError> {
let config =
self.config
.submodules
.get(name)
.ok_or_else(|| SubmoduleError::SubmoduleNotFound {
name: name.to_string(),
})?;
let submodule_path = config.path.as_ref().ok_or_else(|| {
SubmoduleError::ConfigError("No path configured for submodule".to_string())
})?;
// Prepare update options (use defaults for now)
let update_opts = crate::config::SubmoduleUpdateOptions::default();
self.git_ops
.update_submodule(submodule_path, &update_opts)
.map_err(|e| {
SubmoduleError::GitoxideError(format!("GitOpsManager update failed: {e}"))
})?;
if self.verbose {
println!("✅ Updated {name} successfully");
}
Ok(())
}
/// Reset submodule using CLI operations
pub fn reset_submodule(&mut self, name: &str) -> Result<(), SubmoduleError> {
let config =
self.config
.submodules
.get(name)
.ok_or_else(|| SubmoduleError::SubmoduleNotFound {
name: name.to_string(),
})?;
let submodule_path = config.path.as_ref().ok_or_else(|| {
SubmoduleError::ConfigError("No path configured for submodule".to_string())
})?;
println!("🔄 Hard resetting {name}...");
// Step 1: Stash changes
println!(" 📦 Stashing working changes...");
match self.git_ops.stash_submodule(submodule_path, true) {
Ok(()) => {}
Err(e) => println!(" ⚠️ Stash warning: {e}"),
}
// Step 2: Hard reset
println!(" 🔄 Resetting to HEAD...");
self.git_ops
.reset_submodule(submodule_path, true)
.map_err(|e| {
SubmoduleError::GitoxideError(format!("GitOpsManager reset failed: {e}"))
})?;
// Step 3: Clean untracked files
println!(" 🧹 Cleaning untracked files...");
self.git_ops
.clean_submodule(submodule_path, true, true)
.map_err(|e| {
SubmoduleError::GitoxideError(format!("GitOpsManager clean failed: {e}"))
})?;
println!("✅ {name} reset complete");
Ok(())
}
/// Initialize submodule - add it first if not registered, then initialize
pub fn init_submodule(&mut self, name: &str) -> Result<(), SubmoduleError> {
let submodules = self.config.clone().submodules;
let config = submodules
.get(name)
.ok_or_else(|| SubmoduleError::SubmoduleNotFound {
name: name.to_string(),
})?;
let path_str = config.path.as_ref().ok_or_else(|| {
SubmoduleError::ConfigError("No path configured for submodule".to_string())
})?;
let url_str = config.url.as_ref().ok_or_else(|| {
SubmoduleError::ConfigError("No URL configured for submodule".to_string())
})?;
let submodule_path = Path::new(path_str);
if submodule_path.exists() && submodule_path.join(".git").exists() {
if self.verbose {
println!("✅ {name} already initialized");
}
// Even if already initialized, check if we need to configure sparse checkout
let sparse_paths_opt = self
.config
.submodules
.sparse_checkouts()
.and_then(|sparse_checkouts| sparse_checkouts.get(name).cloned());
if let Some(sparse_paths) = sparse_paths_opt {
let use_git_default = self.effective_use_git_default_sparse_checkout(name);
self.configure_sparse_checkout(path_str, &sparse_paths, use_git_default)?;
}
return Ok(());
}
if self.verbose {
println!("🔄 Initializing {name}...");
}
let workdir = std::path::Path::new(".");
// First check if submodule is registered in .gitmodules
let gitmodules_path = workdir.join(".gitmodules");
let needs_add = if gitmodules_path.exists() {
let gitmodules_content = fs::read_to_string(&gitmodules_path)?;
!gitmodules_content.contains(&format!("path = {path_str}"))
} else {
true
};
if needs_add {
// Submodule not registered yet, add it first via GitOpsManager
let opts = crate::config::SubmoduleAddOptions {
name: name.to_string(),
path: std::path::PathBuf::from(path_str),
url: url_str.to_string(),
branch: config.branch.clone(),
ignore: config.ignore,
update: config.update.clone(),
fetch_recurse: config.fetch_recurse,
shallow: config.shallow.unwrap_or(false),
no_init: false,
};
self.git_ops
.add_submodule(&opts)
.map_err(Self::map_git_ops_error)?;
} else {
// Submodule is registered, just initialize and update using GitOperations
self.git_ops
.init_submodule(path_str)
.map_err(Self::map_git_ops_error)?;
let update_opts = crate::config::SubmoduleUpdateOptions::default();
self.git_ops
.update_submodule(path_str, &update_opts)
.map_err(Self::map_git_ops_error)?;
}
if self.verbose {
println!(" ✅ Initialized using git submodule commands: {path_str}");
}
// Configure sparse checkout if specified
if let Some(sparse_checkouts) = submodules.sparse_checkouts()
&& let Some(sparse_paths) = sparse_checkouts.get(name) {
let use_git_default = self.effective_use_git_default_sparse_checkout(name);
self.configure_sparse_checkout(path_str, sparse_paths, use_git_default)?;
}
if self.verbose {
println!("✅ {name} initialized");
}
Ok(())
}
/// Check all submodules using gitoxide APIs where possible
pub fn check_all_submodules(&self) -> Result<(), SubmoduleError> {
if self.verbose {
println!("Checking submodule configurations...");
}
for (submodule_name, submodule) in self.config.get_submodules() {
// Handle missing path gracefully - report but don't fail
let path_str = if let Some(path) = submodule.path.as_ref() {
path
} else {
// Always show errors regardless of verbosity
println!(" ❌ {submodule_name}: No path configured");
continue;
};
// Handle missing URL gracefully - report but don't fail
if submodule.url.is_none() {
println!(" ❌ {submodule_name}: No URL configured");
continue;
}
let submodule_path = Path::new(path_str);
let git_path = submodule_path.join(".git");
if !submodule_path.exists() {
println!(" ❌ {submodule_name}: Folder missing ({path_str})");
continue;
}
if !git_path.exists() {
println!(" ❌ {submodule_name}: Not a git repository");
continue;
}
// GITOXIDE API: Use gix::open and status check
match self.check_submodule_repository_status(path_str, submodule_name) {
Ok(status) => {
if self.verbose {
println!("\n📁 {submodule_name}");
println!(" ✅ Git repository exists");
if status.is_clean {
println!(" ✅ Working tree is clean");
} else {
println!(" ⚠️ Working tree has changes");
}
if let Some(commit) = &status.current_commit {
println!(" ✅ Current commit: {}", &commit[..8]);
}
if status.has_remotes {
println!(" ✅ Has remotes configured");
} else {
println!(" ⚠️ No remotes configured");
}
match &status.sparse_status {