Skip to content

Commit 6601c53

Browse files
committed
impr: add support for submodules in workspace
1 parent 2b9ace2 commit 6601c53

File tree

3 files changed

+269
-36
lines changed

3 files changed

+269
-36
lines changed

src/lib.rs

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,19 @@ impl RepoPattern {
7272
}
7373
}
7474

75+
/// Represents a git submodule within a repository
76+
#[derive(Debug, Clone)]
77+
pub struct SubmoduleInfo {
78+
/// The submodule name from .gitmodules
79+
pub name: String,
80+
/// Relative path within parent repo
81+
pub path: PathBuf,
82+
/// Clone URL
83+
pub url: String,
84+
/// Whether submodule is checked out
85+
pub initialized: bool,
86+
}
87+
7588
/// Recursively find "top-level" git repositories.
7689
/// This function will not traverse into .git directories or nested git repositories.
7790
/// Find all git repositories in the given path
@@ -115,6 +128,86 @@ pub fn find_git_repositories(path: &str) -> Result<Vec<PathBuf>> {
115128
Ok(found)
116129
}
117130

131+
/// Find all submodules in a git repository by parsing the .gitmodules file
132+
pub fn find_submodules_in_repo(repo_path: &Path) -> Result<Vec<SubmoduleInfo>> {
133+
let gitmodules_path = repo_path.join(".gitmodules");
134+
135+
// If .gitmodules doesn't exist, return empty vec
136+
if !gitmodules_path.exists() {
137+
return Ok(Vec::new());
138+
}
139+
140+
let content = std::fs::read_to_string(&gitmodules_path)?;
141+
let mut submodules = Vec::new();
142+
143+
// Simple parser for .gitmodules INI format
144+
let mut current_name: Option<String> = None;
145+
let mut current_path: Option<PathBuf> = None;
146+
let mut current_url: Option<String> = None;
147+
148+
for line in content.lines() {
149+
let line = line.trim();
150+
151+
// Skip empty lines and comments
152+
if line.is_empty() || line.starts_with('#') || line.starts_with(';') {
153+
continue;
154+
}
155+
156+
// Parse [submodule "name"] section headers
157+
if line.starts_with('[') && line.ends_with(']') {
158+
// Save previous submodule if we have all required fields
159+
if let (Some(name), Some(path), Some(url)) =
160+
(current_name.take(), current_path.take(), current_url.take())
161+
{
162+
// Check if submodule is initialized
163+
let initialized = repo_path.join(&path).join(".git").exists();
164+
165+
submodules.push(SubmoduleInfo {
166+
name: name.clone(),
167+
path,
168+
url,
169+
initialized,
170+
});
171+
}
172+
173+
// Extract submodule name from [submodule "name"]
174+
if let Some(start) = line.find('"')
175+
&& let Some(end) = line.rfind('"')
176+
&& start < end
177+
{
178+
current_name = Some(line[start + 1..end].to_string());
179+
}
180+
continue;
181+
}
182+
183+
// Parse key = value lines
184+
if let Some(eq_pos) = line.find('=') {
185+
let key = line[..eq_pos].trim();
186+
let value = line[eq_pos + 1..].trim();
187+
188+
match key {
189+
"path" => current_path = Some(PathBuf::from(value)),
190+
"url" => current_url = Some(value.to_string()),
191+
_ => {} // Ignore other fields
192+
}
193+
}
194+
}
195+
196+
// Don't forget the last submodule
197+
if let (Some(name), Some(path), Some(url)) = (current_name, current_path, current_url) {
198+
let initialized = repo_path.join(&path).join(".git").exists();
199+
200+
submodules.push(SubmoduleInfo {
201+
name,
202+
path,
203+
url,
204+
initialized,
205+
});
206+
}
207+
208+
Ok(submodules)
209+
}
210+
118211
/// Repository status information
119212
#[derive(Debug)]
120213
pub enum RepoStatus {

src/tui/mod.rs

Lines changed: 78 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -777,10 +777,24 @@ fn ui(f: &mut Frame, app: &mut App) {
777777

778778
// Add status icon for repos only
779779
if let Some(ref repo) = node.repo_info {
780-
if repo.is_clean {
781-
spans.push(Span::styled("✓ ", Style::default().fg(Color::Green)));
780+
if repo.is_submodule {
781+
// Submodule indicator
782+
if repo.submodule_initialized {
783+
spans.push(Span::styled("📦 ", Style::default().fg(Color::Magenta)));
784+
} else {
785+
spans.push(Span::styled("📦 ", Style::default().fg(Color::DarkGray)));
786+
spans.push(Span::styled(
787+
"(uninit) ",
788+
Style::default().fg(Color::DarkGray),
789+
));
790+
}
782791
} else {
783-
spans.push(Span::styled("⚠ ", Style::default().fg(Color::Yellow)));
792+
// Regular repo status
793+
if repo.is_clean {
794+
spans.push(Span::styled("✓ ", Style::default().fg(Color::Green)));
795+
} else {
796+
spans.push(Span::styled("⚠ ", Style::default().fg(Color::Yellow)));
797+
}
784798
}
785799
}
786800

@@ -1216,38 +1230,66 @@ fn render_highlighted_name<'a>(
12161230

12171231
/// Collect workspace repositories with metadata
12181232
fn collect_workspace_repos(workspace: &Workspace) -> Vec<RepoInfo> {
1219-
find_git_repositories(&workspace.path)
1220-
.unwrap_or_default()
1221-
.into_iter()
1222-
.map(|path| {
1223-
let display_name = path
1224-
.strip_prefix(&workspace.path)
1225-
.unwrap_or(&path)
1226-
.display()
1227-
.to_string()
1228-
.trim_start_matches('/')
1229-
.to_string();
1230-
1231-
// Check repo status and get modification time in a single repo open for performance
1232-
let (status, modification_time) = crate::check_repo_status_and_modification_time(&path)
1233-
.unwrap_or((crate::RepoStatus::NoCommits, None));
1234-
1235-
// A repo is only clean if it has commits, no changes, and no unpushed commits
1236-
let is_clean = matches!(status, crate::RepoStatus::Clean);
1237-
1238-
// Size not computed for workspace repos to save time
1239-
let size_bytes = None;
1233+
let repos = find_git_repositories(&workspace.path).unwrap_or_default();
1234+
let mut repo_infos = Vec::new();
1235+
1236+
for path in repos {
1237+
let display_name = path
1238+
.strip_prefix(&workspace.path)
1239+
.unwrap_or(&path)
1240+
.display()
1241+
.to_string()
1242+
.trim_start_matches('/')
1243+
.to_string();
1244+
1245+
// Check repo status and get modification time in a single repo open for performance
1246+
let (status, modification_time) = crate::check_repo_status_and_modification_time(&path)
1247+
.unwrap_or((crate::RepoStatus::NoCommits, None));
1248+
1249+
// A repo is only clean if it has commits, no changes, and no unpushed commits
1250+
let is_clean = matches!(status, crate::RepoStatus::Clean);
1251+
1252+
// Size not computed for workspace repos to save time
1253+
let size_bytes = None;
1254+
1255+
// Add the main repository
1256+
repo_infos.push(RepoInfo {
1257+
path: path.clone(),
1258+
display_name: display_name.clone(),
1259+
is_clean,
1260+
modification_time,
1261+
size_bytes,
1262+
operation_status: tree::RepoOperationStatus::None,
1263+
is_submodule: false,
1264+
submodule_initialized: false,
1265+
parent_repo_path: None,
1266+
});
1267+
1268+
// Find and add submodules
1269+
if let Ok(submodules) = crate::find_submodules_in_repo(&path) {
1270+
for submodule in submodules {
1271+
let submodule_display_name = if display_name.is_empty() {
1272+
submodule.path.display().to_string()
1273+
} else {
1274+
format!("{}/{}", display_name, submodule.path.display())
1275+
};
12401276

1241-
RepoInfo {
1242-
path,
1243-
display_name,
1244-
is_clean,
1245-
modification_time,
1246-
size_bytes,
1247-
operation_status: tree::RepoOperationStatus::None,
1277+
repo_infos.push(RepoInfo {
1278+
path: path.join(&submodule.path),
1279+
display_name: submodule_display_name,
1280+
is_clean: true, // Submodule status computed separately
1281+
modification_time: None,
1282+
size_bytes: None,
1283+
operation_status: tree::RepoOperationStatus::None,
1284+
is_submodule: true,
1285+
submodule_initialized: submodule.initialized,
1286+
parent_repo_path: Some(path.clone()),
1287+
});
12481288
}
1249-
})
1250-
.collect()
1289+
}
1290+
}
1291+
1292+
repo_infos
12511293
}
12521294

12531295
/// Collect library repositories with metadata
@@ -1270,6 +1312,9 @@ fn collect_library_repos(workspace: &Workspace) -> Vec<RepoInfo> {
12701312
modification_time,
12711313
size_bytes,
12721314
operation_status: tree::RepoOperationStatus::None,
1315+
is_submodule: false,
1316+
submodule_initialized: false,
1317+
parent_repo_path: None,
12731318
}
12741319
})
12751320
.collect()

src/tui/tree.rs

Lines changed: 98 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,12 @@ pub struct RepoInfo {
3939
pub size_bytes: Option<u64>,
4040
/// Current operation status
4141
pub operation_status: RepoOperationStatus,
42+
/// Whether this repo is a submodule
43+
pub is_submodule: bool,
44+
/// Whether this submodule is initialized (checked out)
45+
pub submodule_initialized: bool,
46+
/// Path to parent repository (for submodules)
47+
pub parent_repo_path: Option<PathBuf>,
4248
}
4349

4450
#[derive(Clone)]
@@ -78,6 +84,30 @@ impl TreeNode {
7884
}
7985
}
8086

87+
pub fn new_submodule(
88+
name: String,
89+
parent_path: PathBuf,
90+
submodule_path: PathBuf,
91+
initialized: bool,
92+
) -> Self {
93+
Self {
94+
name,
95+
repo_info: Some(RepoInfo {
96+
path: parent_path.join(&submodule_path),
97+
display_name: submodule_path.display().to_string(),
98+
is_clean: true, // Submodule status computed separately
99+
modification_time: None,
100+
size_bytes: None,
101+
operation_status: RepoOperationStatus::None,
102+
is_submodule: true,
103+
submodule_initialized: initialized,
104+
parent_repo_path: Some(parent_path),
105+
}),
106+
children: Vec::new(),
107+
expanded: false, // Submodules start collapsed
108+
}
109+
}
110+
81111
/// Flatten the tree into a list of (node, depth, index_path, full_path) tuples
82112
pub fn flatten(
83113
&self,
@@ -142,8 +172,13 @@ impl TreeNode {
142172

143173
/// Build a tree structure from a flat list of repos
144174
pub fn build_tree(mut repos: Vec<RepoInfo>) -> Vec<TreeNode> {
145-
// Sort repos by modification time (most recent first)
146-
repos.sort_by(|a, b| {
175+
// Separate regular repos from submodules
176+
let (submodules, regular_repos): (Vec<_>, Vec<_>) =
177+
repos.drain(..).partition(|r| r.is_submodule);
178+
179+
// Sort regular repos by modification time (most recent first)
180+
let mut sorted_repos = regular_repos;
181+
sorted_repos.sort_by(|a, b| {
147182
match (a.modification_time, b.modification_time) {
148183
(Some(a_time), Some(b_time)) => b_time.cmp(&a_time), // Most recent first
149184
(Some(_), None) => std::cmp::Ordering::Less, // Items with time come first
@@ -154,7 +189,8 @@ pub fn build_tree(mut repos: Vec<RepoInfo>) -> Vec<TreeNode> {
154189

155190
let mut root_nodes: Vec<TreeNode> = Vec::new();
156191

157-
for repo in repos {
192+
// Build tree from regular repos
193+
for repo in sorted_repos {
158194
let parts: Vec<&str> = repo.display_name.split('/').collect();
159195

160196
if parts.is_empty() {
@@ -189,9 +225,68 @@ pub fn build_tree(mut repos: Vec<RepoInfo>) -> Vec<TreeNode> {
189225
}
190226
}
191227

228+
// Now insert submodules as children of their parent repos
229+
for submodule in submodules {
230+
insert_submodule_into_tree(&mut root_nodes, submodule);
231+
}
232+
192233
root_nodes
193234
}
194235

236+
/// Helper function to insert a submodule into the tree as a child of its parent repo
237+
fn insert_submodule_into_tree(root_nodes: &mut Vec<TreeNode>, submodule: RepoInfo) {
238+
let parent_path = match &submodule.parent_repo_path {
239+
Some(path) => path,
240+
None => return, // Shouldn't happen, but skip if no parent
241+
};
242+
243+
// Find the parent repo node
244+
if let Some(parent_node) = find_repo_node_by_path(root_nodes, parent_path) {
245+
// Create submodule node
246+
let submodule_name = submodule
247+
.path
248+
.file_name()
249+
.and_then(|n| n.to_str())
250+
.unwrap_or(&submodule.display_name)
251+
.to_string();
252+
253+
let submodule_node = TreeNode::new_submodule(
254+
submodule_name,
255+
parent_path.clone(),
256+
submodule
257+
.path
258+
.strip_prefix(parent_path)
259+
.unwrap_or(&submodule.path)
260+
.to_path_buf(),
261+
submodule.submodule_initialized,
262+
);
263+
264+
// Add as child of parent
265+
parent_node.children.push(submodule_node);
266+
}
267+
}
268+
269+
/// Recursively find a tree node by its repository path
270+
fn find_repo_node_by_path<'a>(
271+
nodes: &'a mut Vec<TreeNode>,
272+
path: &PathBuf,
273+
) -> Option<&'a mut TreeNode> {
274+
for node in nodes {
275+
// Check if this node matches
276+
if let Some(ref repo_info) = node.repo_info
277+
&& &repo_info.path == path
278+
{
279+
return Some(node);
280+
}
281+
282+
// Recursively check children
283+
if let Some(found) = find_repo_node_by_path(&mut node.children, path) {
284+
return Some(found);
285+
}
286+
}
287+
None
288+
}
289+
195290
/// Build library tree, excluding repos that exist in workspace
196291
pub fn build_library_tree(
197292
library_repos: Vec<RepoInfo>,

0 commit comments

Comments
 (0)