use crate::fleet::error::{Result, SuperGitError};
use crate::fleet::git::{GitOps, SubmoduleTree};
use crate::fleet::output::Formatter;
use crate::git_backend;
use crate::prelude::*;
pub enum Verdict {
Changed(String),
Unchanged(String),
Blocked(String),
}
pub fn pull_one(
repo: &gix::Repository,
label: &str,
options: git_backend::PullOptions,
) -> std::result::Result<Verdict, String> {
match git_backend::pull(repo, options) {
Ok(git_backend::PullOutcome::FastForwarded {
commits,
discarded,
restored,
}) => {
let mut message = format!("Pulled {commits} commit(s) in '{label}'");
if !discarded.is_empty() {
message.push_str(&format!(
" — discarded local changes to {}",
discarded.join(", ")
));
}
if !restored.is_empty() {
message.push_str(&format!(
" — set aside and restored your changes to {}",
restored.join(", ")
));
}
Ok(Verdict::Changed(message))
}
Ok(git_backend::PullOutcome::Rebased { commits }) => Ok(Verdict::Changed(format!(
"Rebased '{label}' onto {commits} incoming commit(s)"
))),
Ok(git_backend::PullOutcome::UpToDate) => {
Ok(Verdict::Unchanged(format!("'{label}' is up to date")))
}
Ok(git_backend::PullOutcome::Ahead { outgoing }) => Ok(Verdict::Unchanged(format!(
"'{label}' is up to date ({outgoing} local commit(s) to push)"
))),
Ok(git_backend::PullOutcome::NoRemoteBranch) => Ok(Verdict::Unchanged(format!(
"'{label}' has no counterpart branch on origin"
))),
Ok(git_backend::PullOutcome::Diverged {
incoming,
outgoing,
}) => Ok(Verdict::Blocked(format!(
"'{label}' has diverged: {incoming} incoming commit(s) and {outgoing} local \
commit(s). Fast-forward is impossible — rerun with `wsb pull --rebase` to \
replay the local commits on top, or merge them by hand."
))),
Ok(git_backend::PullOutcome::LocalChangesBlock {
incoming,
paths,
untracked,
}) => {
let mut message = format!(
"'{label}': {incoming} incoming commit(s) rewrite {} file(s) this tree has \
changed:\n {}",
paths.len() + untracked.len(),
paths
.iter()
.chain(untracked.iter())
.cloned()
.collect::<Vec<_>>()
.join("\n ")
);
if untracked.is_empty() {
message.push_str(
"\n Rerun with `wsb pull --autostash` to set those changes aside and \
put them back on top, or `wsb pull --overwrite-local` to throw them \
away. Nothing else in the tree is touched either way.",
);
} else {
message.push_str(&format!(
"\n {} untracked, so neither --autostash nor --overwrite-local \
applies to {}: git holds no copy to put back. Move or remove {} by \
hand.",
if untracked.len() == 1 {
"One of these is"
} else {
"Some of these are"
},
untracked.join(", "),
if untracked.len() == 1 { "it" } else { "them" }
));
}
Ok(Verdict::Blocked(message))
}
Ok(git_backend::PullOutcome::AutostashConflict {
incoming,
paths,
stash,
}) => Ok(Verdict::Blocked(format!(
"'{label}': took {incoming} incoming commit(s), but your changes to {} could \
not be replayed on top of them — the same lines moved on both sides. Your \
work is safe in {stash}; `git stash show -p {stash}` shows it and \
`git checkout {stash} -- <file>` takes a side.",
paths.join(", ")
))),
Err(err) => Err(format!("Failed to pull in '{label}': {err}")),
}
}
pub fn execute<P: AsRef<Path>>(
repo_root: P,
options: git_backend::PullOptions,
dry_run: bool,
verbose: bool,
) -> Result<()> {
let tree = SubmoduleTree::discover(&repo_root)?;
Formatter::print_info(&format!(
"Pulling latest changes in superproject + {} submodule(s){}",
tree.len(),
if options.rebase { " (with rebase)" } else { "" }
));
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;
if verbose {
Formatter::print_submodule_header("[SUPERPROJECT]");
}
{
let repo = GitOps::open_repo(&repo_root)?;
if dry_run {
let state = git_backend::remote_state(&repo, Some("origin"))?;
match state {
git_backend::RemoteState::Behind { incoming } => {
Formatter::print_info(&format!(
"Would fast-forward superproject {incoming} commit(s)"
));
successful += 1;
}
git_backend::RemoteState::Diverged { .. } if options.rebase => {
Formatter::print_info(&format!(
"Would rebase superproject ({})",
state.describe()
));
successful += 1;
}
git_backend::RemoteState::Diverged { .. } => {
Formatter::print_error(&format!("Superproject {}", state.describe()));
failed += 1;
}
other => {
Formatter::print_info(&format!("Superproject {}", other.describe()));
skipped += 1;
}
}
} else {
match pull_one(&repo, "superproject", options) {
Ok(Verdict::Changed(message)) => {
Formatter::print_success(&message);
successful += 1;
}
Ok(Verdict::Unchanged(message)) => {
Formatter::print_info(&message);
skipped += 1;
}
Ok(Verdict::Blocked(message)) | Err(message) => {
Formatter::print_error(&message);
failed += 1;
}
}
}
}
for node in tree.top_down() {
if verbose {
Formatter::print_submodule_header(node.label());
}
if !node.initialized {
Formatter::print_warning(&format!(
"Submodule '{}' not initialized, skipping",
node.label()
));
skipped += 1;
continue;
}
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;
}
};
let is_detached = match GitOps::is_detached_head(&repo) {
Ok(d) => d,
Err(e) => {
Formatter::print_error(&format!(
"Failed to check HEAD state of '{}': {}",
node.label(),
e
));
failed += 1;
continue;
}
};
if is_detached {
Formatter::print_warning(&format!(
"Submodule '{}' is in detached HEAD state, skipping",
node.label()
));
skipped += 1;
continue;
}
if dry_run {
match git_backend::remote_state(&repo, Some("origin")) {
Ok(state) if state.has_incoming() => {
Formatter::print_info(&format!(
"Would pull '{}' ({})",
node.label(),
state.describe()
));
successful += 1;
}
Ok(state) => {
if verbose {
Formatter::print_info(&format!(
"'{}' {}",
node.label(),
state.describe()
));
}
skipped += 1;
}
Err(e) => {
Formatter::print_error(&format!(
"Failed to determine the state of '{}': {}",
node.label(),
e
));
failed += 1;
}
}
continue;
}
match pull_one(&repo, node.label(), options) {
Ok(Verdict::Changed(message)) => {
Formatter::print_success(&message);
successful += 1;
}
Ok(Verdict::Unchanged(message)) => {
if verbose {
Formatter::print_info(&message);
}
skipped += 1;
}
Ok(Verdict::Blocked(message)) | Err(message) => {
Formatter::print_error(&message);
failed += 1;
}
}
}
if !dry_run {
Formatter::print_summary(successful, failed, skipped);
}
if failed > 0 {
return Err(SuperGitError::Other(format!(
"{} repository/repositories failed to pull",
failed
)));
}
Ok(())
}
pub fn bring_main_in(project_root: &Path, dry_run: bool) -> std::result::Result<(), String> {
use crate::git_backend::{PullOptions, PullOutcome};
let tree = crate::fleet::git::tree::SubmoduleTree::discover_allow_empty(project_root)
.map_err(|e| format!("discovering submodules under {}: {e}", project_root.display()))?;
let mut repositories = vec![project_root.to_path_buf()];
repositories.extend(tree.top_down().iter().filter(|node| node.initialized).map(|node| node.abs_path.clone()));
for repository in &repositories {
let label = match repository.strip_prefix(project_root) {
Ok(relative) if relative.as_os_str().is_empty() => ".".to_string(),
Ok(relative) => relative.display().to_string(),
Err(_) => repository.display().to_string(),
};
let repo = crate::git_backend::open_repo(repository).map_err(|e| format!("{label}: {e}"))?;
if repo.find_remote("origin").is_err() {
continue;
}
if dry_run {
let state = crate::git_backend::remote_state(&repo, Some("origin")).map_err(|e| format!("{label}: {e}"))?;
if state.has_incoming() {
crate::outln!(" {label}: would take {}", state.describe());
}
continue;
}
match crate::git_backend::pull(&repo, PullOptions::default()).map_err(|e| format!("{label}: {e}"))? {
PullOutcome::FastForwarded { commits, .. } => crate::outln!(" {label}: took {commits} commit(s)"),
PullOutcome::UpToDate | PullOutcome::Ahead { .. } | PullOutcome::NoRemoteBranch => {}
PullOutcome::Rebased { .. } | PullOutcome::AutostashConflict { .. } => {
return Err(format!("{label}: a plain pull rebased or stashed, which this checkpoint did not ask for"))
}
PullOutcome::Diverged { incoming, outgoing } => return Err(format!(
"{label}: {incoming} commit(s) on main and {outgoing} of its own not pushed — main moved past \
work here. Nothing was committed; bring them together with `wsb pull --rebase`, then checkpoint."
)),
PullOutcome::LocalChangesBlock { paths, untracked, .. } => {
let rendered = crate::state::state_common::files_an_update_writes(repository).map_err(|e| format!("{label}: {e:#}"))?;
let place = crate::platform::canonicalize(repository)
.map_err(|e| format!("resolving {}: {e}", repository.display()))?;
let somebody_s: Vec<&String> = paths
.iter()
.filter(|path| !rendered.contains(&place.join(path)))
.chain(untracked.iter())
.collect();
if !somebody_s.is_empty() {
return Err(format!(
"{label}: the commits on main rewrite {}, changed here and not rendered — somebody's \
work. Nothing was committed; commit or set it aside, then checkpoint.",
somebody_s.iter().map(|p| p.as_str()).collect::<Vec<_>>().join(", ")
));
}
let dropping = PullOptions { overwrite_local: true, ..Default::default() };
match crate::git_backend::pull(&repo, dropping).map_err(|e| format!("{label}: {e}"))? {
PullOutcome::FastForwarded { commits, discarded, .. } => crate::outln!(
" {label}: took {commits} commit(s), dropping the rendered {} — the commit renders it again",
discarded.join(", ")
),
other => return Err(format!("{label}: dropping only rendered files did not let main in: {other:?}")),
}
}
}
}
Ok(())
}