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
use crate::fleet::error::{Result, SuperGitError};
use crate::fleet::git::{GitOps, SubmoduleNode, SubmoduleOps, SubmoduleTree};
use crate::fleet::output::Formatter;
use crate::git_backend;
use crate::prelude::*;
use crate::workspace_state::WorkspaceState;
use std::collections::HashMap;
/// The order a fleet's submodules are committed in.
///
/// Two rules, one order. A child before its parent, which records the
/// child's new commit. And a repository after every repository whose version
/// its templates read (`state::wstemplate::render_dependencies`): a commit
/// moves its repository's version, and a commit renders, so a repository
/// committed before one it reads was rendered from that one's version a moment
/// before it moved. Ordered by nesting alone, every checkpoint committed
/// floom-uikit's and machine-notation-lsp's locks against capdag-js's and
/// tagged-urn's previous versions, and the next checkpoint committed the
/// catch-up. Ties keep the deepest-first order; a cycle is refused, naming it.
fn commit_order<'a>(tree: &'a SubmoduleTree, root: &Path) -> Result<Vec<&'a SubmoduleNode>> {
let nodes = tree.bottom_up();
let index: HashMap<&Path, usize> = nodes.iter().enumerate().map(|(i, node)| (node.abs_path.as_path(), i)).collect();
let paths: Vec<PathBuf> = nodes.iter().map(|node| node.abs_path.clone()).collect();
let mut repositories = paths.clone();
repositories.push(root.to_path_buf());
let reads = crate::state::wstemplate::render_dependencies(root, &repositories)
.map_err(|e| SuperGitError::Other(format!("reading which repositories each one's templates read: {e:#}")))?;
// `before[i]`: the nodes that must be committed before node `i`.
let mut before: Vec<std::collections::BTreeSet<usize>> = vec![Default::default(); nodes.len()];
for (i, node) in nodes.iter().enumerate() {
if let Some(&parent) = index.get(node.parent_path.as_path()) {
before[parent].insert(i);
}
for read in reads.get(&node.abs_path).into_iter().flatten() {
if let Some(&dependency) = index.get(read.as_path()) {
before[i].insert(dependency);
}
}
}
let mut order = Vec::with_capacity(nodes.len());
let mut done = vec![false; nodes.len()];
while order.len() < nodes.len() {
let next = (0..nodes.len()).find(|&i| !done[i] && before[i].iter().all(|&b| done[b]));
match next {
Some(i) => {
done[i] = true;
order.push(nodes[i]);
}
None => {
let stuck: Vec<&str> = (0..nodes.len()).filter(|&i| !done[i]).map(|i| nodes[i].label()).collect();
return Err(SuperGitError::Other(format!(
"these repositories read each other's versions or nest in a way that leaves no order to commit them in: {}",
stuck.join(", ")
)));
}
}
}
Ok(order)
}
#[allow(clippy::too_many_arguments)]
pub fn execute<P: AsRef<Path>>(
repo_root: P,
message: &str,
push: bool,
stage_all: bool,
exclude: &[String],
include: &[String],
force_all: bool,
dry_run: bool,
verbose: bool,
) -> Result<()> {
let tree = SubmoduleTree::discover(&repo_root)?;
Formatter::print_info(&format!(
"Committing changes in superproject + {} submodule(s)",
tree.len()
));
if dry_run {
Formatter::print_warning("DRY RUN: No changes will be made");
}
let mut successful = 0;
let mut failed = 0;
let mut skipped = 0;
// Gitlinks created by committing a child, grouped by the parent that must
// record them. Populated as we go and drained when we reach that parent.
let mut pending_gitlinks: HashMap<PathBuf, Vec<String>> = HashMap::new();
// Children before their parent — a parent can only record a child's new
// commit after that commit exists — and every repository after those whose
// versions its templates read (`commit_order`).
for node in commit_order(&tree, repo_root.as_ref())? {
if verbose {
Formatter::print_submodule_header(node.label());
}
if !selected(node, include, exclude, verbose)? {
skipped += 1;
continue;
}
if !node.initialized {
Formatter::print_warning(&format!(
"Submodule '{}' not initialized, skipping",
node.label()
));
skipped += 1;
continue;
}
// Resolved before anything is mutated: if we could not name this path to
// git afterwards, we would commit a child whose gitlink can never be
// recorded in its parent.
let gitlink = node.rel_path.to_str().ok_or_else(|| {
SuperGitError::Other(format!(
"Submodule path '{}' is not valid UTF-8 and cannot be staged in its parent",
node.rel_path.display()
))
})?;
let repo = match GitOps::open_repo(&node.abs_path) {
Ok(r) => r,
Err(e) => {
Formatter::print_error(&format!("Failed to open '{}': {}", node.label(), e));
failed += 1;
continue;
}
};
// Record the gitlinks of any children we already committed in this run.
// This must happen before the cleanliness check, since these staged
// gitlinks may be the only thing this repository has to commit.
let child_links = pending_gitlinks.remove(&node.abs_path).unwrap_or_default();
if !child_links.is_empty() && !dry_run {
if let Err(e) = git_backend::stage_paths(&repo, &child_links) {
Formatter::print_error(&format!(
"Failed to record nested submodule commits in '{}': {}",
node.label(),
e
));
failed += 1;
continue;
}
}
let is_clean = match GitOps::is_clean(&repo) {
Ok(c) => c,
Err(e) => {
Formatter::print_error(&format!(
"Failed to check status of '{}': {}",
node.label(),
e
));
failed += 1;
continue;
}
};
if is_clean && child_links.is_empty() && !force_all {
if verbose {
Formatter::print_info(&format!("No changes in '{}'", node.label()));
}
skipped += 1;
continue;
}
if dry_run {
if stage_all {
Formatter::print_info("Would stage all changes (git add -A)");
}
if !child_links.is_empty() {
Formatter::print_info(&format!(
"Would record {} nested submodule commit(s)",
child_links.len()
));
}
Formatter::print_info("Would commit changes");
successful += 1;
// Keep the dry-run picture honest: the parent would see this gitlink.
pending_gitlinks
.entry(node.parent_path.clone())
.or_default()
.push(gitlink.to_string());
continue;
}
if stage_all {
if let Err(e) = git_backend::stage_all(&repo) {
Formatter::print_error(&format!("Failed to stage in '{}': {}", node.label(), e));
failed += 1;
continue;
}
}
match SubmoduleOps::commit(node, message) {
Ok(_) => {
Formatter::print_success(&format!("Committed changes in '{}'", node.label()));
successful += 1;
pending_gitlinks
.entry(node.parent_path.clone())
.or_default()
.push(gitlink.to_string());
if push {
match SubmoduleOps::push(node, false) {
Ok(_) => {
Formatter::print_success(&format!(
"Pushed changes in '{}'",
node.label()
));
}
Err(e) => {
Formatter::print_error(&format!(
"Failed to push in '{}': {}",
node.label(),
e
));
failed += 1;
}
}
}
}
Err(e) => {
Formatter::print_error(&format!("Failed to commit in '{}': {}", node.label(), e));
failed += 1;
}
}
}
if dry_run {
if pending_gitlinks.contains_key(repo_root.as_ref()) {
Formatter::print_info("Would update superproject to reference new commits");
}
return Ok(());
}
// Every parent in the tree has been visited by the loop above, so anything
// still pending belongs to the fleet root itself.
let root_links = pending_gitlinks
.remove(repo_root.as_ref())
.unwrap_or_default();
if !pending_gitlinks.is_empty() {
let orphans: Vec<String> = pending_gitlinks
.keys()
.map(|path| path.display().to_string())
.collect();
return Err(SuperGitError::Other(format!(
"Internal error: committed submodules whose parent was never visited: {}. \
The tree would be left with unrecorded gitlinks.",
orphans.join(", ")
)));
}
Formatter::print_summary(successful, failed, skipped);
// The superproject is a repository too, and this used to run only when a
// child had moved. A change to a file at the fleet root and nowhere else —
// `workspace.json`, a document, a version — was staged by nobody and
// committed by nobody, and the summary said "0 failed": a checkpoint that
// reported success and left the edit sitting in the working tree.
//
// And only when there IS something: a clean superproject whose children
// did not move is skipped, as a clean submodule is above. Handed to `git
// commit` anyway, it was never "nothing to commit" — git runs the
// pre-commit hook first, the hook renders the version a commit being made
// gets (one more than the clean tree's) and stages it, and git then had a
// change to commit. Every checkpoint made a superproject commit of nothing
// but its own version.
let root_has_changes = !root_links.is_empty() || !GitOps::is_clean(&git_backend::open_repo(repo_root.as_ref())?)?;
if root_has_changes || force_all {
let repo = git_backend::open_repo(repo_root.as_ref())?;
if !root_links.is_empty() {
Formatter::print_info("Updating superproject...");
git_backend::stage_paths(&repo, &root_links)?;
}
if stage_all {
git_backend::stage_all(&repo)?;
}
// "Update submodules" only when that is what happened. A commit
// carrying one edited file and no gitlink is not an update of anything
// below it.
let subject = if root_links.is_empty() {
message.to_string()
} else {
format!("Update submodules: {}", message)
};
match git_backend::commit_all(&repo, &subject) {
Ok(Some(_)) => {
Formatter::print_success("Committed in superproject");
if push {
match git_backend::push_current_branch(&repo, Some("origin"), true, false) {
Ok(_) => Formatter::print_success("Pushed superproject"),
Err(err) => {
Formatter::print_error(&format!(
"Failed to push superproject: {}",
err
));
failed += 1;
}
}
}
}
// Genuinely nothing: no gitlink moved and no file at the root
// changed. Said only when asked, because that is the ordinary case
// for a fleet whose work all happens in its submodules.
Ok(None) => {
if verbose {
Formatter::print_info("Nothing to commit in the superproject");
}
}
Err(err) => {
Formatter::print_error(&format!("Failed to commit in superproject: {}", err));
failed += 1;
}
}
} else if verbose {
Formatter::print_info("Nothing to commit in the superproject");
}
if failed > 0 {
return Err(SuperGitError::Other(format!(
"{} submodule(s) failed to commit",
failed
)));
}
Ok(())
}
/// Apply `--include` / `--exclude` against a submodule's workspace alias, falling
/// back to its path from the fleet root when it declares no alias.
fn selected(
node: &SubmoduleNode,
include: &[String],
exclude: &[String],
verbose: bool,
) -> Result<bool> {
if include.is_empty() && exclude.is_empty() {
return Ok(true);
}
let alias = WorkspaceState::load(&node.abs_path)
.ok()
.and_then(|state| state.wstemplate_entry().map(|entry| entry.alias.clone()));
// A submodule matches on its alias, its name, or its full path from the root,
// so nested submodules can be addressed unambiguously.
let matches = |candidate: &String| {
alias.as_ref() == Some(candidate)
|| &node.name == candidate
|| &node.display_path == candidate
};
let label = alias.as_deref().unwrap_or(&node.display_path);
if !include.is_empty() && !include.iter().any(matches) {
if verbose {
Formatter::print_info(&format!("Skipping '{}' (not in --include list)", label));
}
return Ok(false);
}
if exclude.iter().any(matches) {
if verbose {
Formatter::print_info(&format!("Skipping '{}' (excluded)", label));
}
return Ok(false);
}
Ok(true)
}
#[cfg(test)]
#[path = "commit_tests.rs"]
mod tests;