use std::path::Path;
use anyhow::{Context, Result, bail};
use git2::BranchType;
use serde::{Deserialize, Serialize};
use crate::core::repo;
use crate::core::agent_mode;
use crate::core::msg;
use crate::core::transaction::{self, LoomState, Rollback};
use crate::core::weave::Weave;
use crate::git::{self, RebaseOutcome};
#[derive(Serialize, Deserialize)]
struct UpdateContext {
branch_name: String,
upstream_name: String,
skip_confirm: bool,
#[serde(default)]
merged_branches: Vec<String>,
}
pub fn run(skip_confirm: bool) -> Result<()> {
let repo = repo::open_repo()?;
let workdir = repo::require_workdir(&repo, "update")?.to_path_buf();
let git_dir = repo.path().to_path_buf();
let head = repo.head().context("Failed to get HEAD reference")?;
if !head.is_branch() {
bail!("HEAD is detached\nSwitch to an integration branch");
}
let branch_name = head
.shorthand()
.context("Could not determine current branch name")?
.to_string();
let local_branch = repo.find_branch(&branch_name, BranchType::Local)?;
let upstream = local_branch.upstream().with_context(|| {
format!(
"Branch `{}` has no upstream tracking branch\n\
Run `loom init` to set up an integration branch",
branch_name
)
})?;
let upstream_name = upstream
.name()?
.context("Upstream branch name is not valid UTF-8")?
.to_string();
let head_refname = head.name().context("HEAD ref name is not valid UTF-8")?;
let upstream_remote = repo
.branch_upstream_remote(head_refname)
.context("Failed to resolve the upstream remote")?;
let upstream_remote = upstream_remote
.as_str()
.context("Upstream remote name is not valid UTF-8")?
.to_string();
let spinner = msg::spinner();
spinner.start("Fetching latest changes...");
let result = git::run_git_combined(
&workdir,
&[
"fetch",
"--no-progress",
"--tags",
"--force",
"--prune",
&upstream_remote,
],
);
match result {
Ok(summary) => {
spinner.stop("Fetched latest changes");
if !summary.is_empty() {
msg::human_line(&summary);
}
}
Err(e) => {
spinner.error("Fetch failed");
return Err(e);
}
}
fetch_push_remote(&repo, &workdir, &upstream_name);
let repo = git2::Repository::discover(&workdir)?;
let (todo, merged_branches) = match Weave::from_repo(&repo) {
Ok(mut graph) => {
let new_upstream_oid = repo
.revparse_single(&upstream_name)
.context("Failed to resolve upstream ref")?
.id();
let filtered_out = graph.filter_upstream_commits(&repo, &workdir, new_upstream_oid)?;
let mut merged = find_branches_merged_upstream(
&repo,
&branch_name,
repo::head_oid(&repo)?,
graph.base_oid,
new_upstream_oid,
)?;
merged.extend(filtered_out);
merged.sort();
merged.dedup();
(Some(graph.to_todo()), merged)
}
Err(_) => (None, Vec::new()),
};
let ctx = UpdateContext {
branch_name: branch_name.clone(),
upstream_name: upstream_name.clone(),
skip_confirm,
merged_branches,
};
let state = LoomState {
command: "update".to_string(),
rollback: Rollback {
saved_staged_patch: git::diff_cached(&workdir)?,
..Default::default()
},
context: serde_json::to_value(&ctx)?,
protect: Vec::new(),
targets: Vec::new(),
};
transaction::save(&git_dir, &state)?;
let spinner = msg::spinner();
spinner.start("Rebasing onto upstream...");
let outcome = match &todo {
Some(todo) => crate::core::weave::run_rebase(&workdir, Some(&upstream_name), todo),
None => git::rebase(&git_dir, &workdir, &upstream_name),
};
match outcome {
Ok(RebaseOutcome::Completed) => {
spinner.stop("Rebased onto upstream");
git::restore_staged_after_rebase(&workdir, &state.rollback.saved_staged_patch);
transaction::delete(&git_dir)?;
let repo2 = git2::Repository::discover(&workdir)?;
post_update(&workdir, &repo2, &ctx)?;
}
Ok(RebaseOutcome::Stopped) => {
spinner.error("Rebase paused");
transaction::warn_paused(&workdir, "update");
}
Ok(RebaseOutcome::Paused) => {
spinner.error("Rebase paused");
transaction::warn_paused_at_edit(Some("update"));
}
Err(e) => {
spinner.error("Rebase failed");
return Err(transaction::discard_state_after(
&workdir,
&git_dir,
&state.rollback.saved_staged_patch,
e,
));
}
}
Ok(())
}
fn fetch_push_remote(repo: &git2::Repository, workdir: &Path, upstream_name: &str) {
let Some(remote) = crate::push::fork_push_remote(repo, workdir, upstream_name) else {
return;
};
let spinner = msg::spinner();
spinner.start(&format!("Fetching `{}`...", remote));
match git::run_git_combined(
workdir,
&[
"fetch",
"--no-progress",
"--prune",
"--no-prune-tags",
&remote,
],
) {
Ok(summary) => {
spinner.stop(&format!("Fetched `{}`", remote));
if !summary.is_empty() {
msg::human_line(&summary);
}
}
Err(_) => {
spinner.error(&format!(
"Could not fetch `{}` — branches deleted there are not detected",
remote
));
}
}
}
pub fn after_continue(
workdir: &Path,
rollback: &Rollback,
context: &serde_json::Value,
) -> Result<()> {
let ctx: UpdateContext =
serde_json::from_value(context.clone()).context("Failed to parse update resume context")?;
git::restore_staged_after_rebase(workdir, &rollback.saved_staged_patch);
let repo = git2::Repository::discover(workdir)?;
post_update(workdir, &repo, &ctx)
}
fn post_update(workdir: &Path, repo: &git2::Repository, ctx: &UpdateContext) -> Result<()> {
if workdir.join(".gitmodules").exists() {
let spinner = msg::spinner();
spinner.start("Updating submodules...");
let result = git::run_git(workdir, &["submodule", "update", "--init", "--recursive"]);
match result {
Ok(()) => {
spinner.stop("Updated submodules");
}
Err(e) => {
spinner.error("Submodule update failed");
return Err(e);
}
}
}
let upstream_info = repo
.revparse_single(&ctx.upstream_name)
.ok()
.and_then(|obj| obj.peel_to_commit().ok())
.map(|commit| {
let short_id = git::short_hash(&commit.id().to_string()).to_string();
let summary = repo::commit_subject(&commit);
format!(" ({} {})", short_id, summary)
})
.unwrap_or_default();
msg::success(&format!(
"Updated branch `{}` with `{}`{}",
ctx.branch_name, ctx.upstream_name, upstream_info
));
let gone = find_branches_with_gone_upstream(repo, &ctx.branch_name)?;
let merged: Vec<String> = ctx
.merged_branches
.iter()
.filter(|name| !gone.contains(name) && repo.find_branch(name, BranchType::Local).is_ok())
.cloned()
.collect();
let to_remove: Vec<&String> = merged.iter().chain(gone.iter()).collect();
if !to_remove.is_empty() {
warn_branch_list(&merged, "fully merged upstream");
warn_branch_list(&gone, "with a gone upstream");
let confirmed = if agent_mode::enabled() && !ctx.skip_confirm {
let confirmed = repo::prune_gone_branches(repo);
if !confirmed {
msg::warn(
"Skipped removing branches (agent mode)\n\
Re-run with `loom update -y` to remove them",
);
}
confirmed
} else {
ctx.skip_confirm
|| repo::prune_gone_branches(repo)
|| msg::confirm(
if to_remove.len() == 1 {
"Remove it?"
} else {
"Remove them?"
},
"re-run with: loom update -y",
)?
};
if confirmed {
for name in to_remove {
let short_id = repo
.revparse_single(name)
.ok()
.map(|obj| git::short_hash(&obj.id().to_string()).to_string());
match git::branch_delete(workdir, name) {
Ok(()) => match &short_id {
Some(id) => {
msg::success(&format!("Removed branch `{}` (was {})", name, id))
}
None => msg::success(&format!("Removed branch `{}`", name)),
},
Err(_) => {
msg::warn(&format!(
"Skipped branch `{}` — could not delete it (run `loom trace` for the git error)",
name
));
}
}
}
}
}
Ok(())
}
fn warn_branch_list(names: &[String], what: &str) {
if names.is_empty() {
return;
}
let mut warn_msg = format!(
"{} local {} {}:",
names.len(),
if names.len() == 1 {
"branch"
} else {
"branches"
},
what
);
for name in names {
warn_msg.push('\n');
warn_msg.push_str(name);
}
msg::warn(&warn_msg);
}
fn find_branches_merged_upstream(
repo: &git2::Repository,
current_branch: &str,
head_oid: git2::Oid,
base_oid: git2::Oid,
upstream_oid: git2::Oid,
) -> Result<Vec<String>> {
let contains = |tip, oid| repo::contains(repo, tip, oid);
let mut merged = Vec::new();
for branch_result in repo.branches(Some(BranchType::Local))? {
let (branch, _) = branch_result?;
let Some(name) = branch.name()? else {
continue;
};
if name == current_branch {
continue;
}
let Some(tip) = branch.get().target() else {
continue;
};
if !contains(base_oid, tip)? && contains(upstream_oid, tip)? && contains(head_oid, tip)? {
merged.push(name.to_string());
}
}
Ok(merged)
}
fn find_branches_with_gone_upstream(
repo: &git2::Repository,
current_branch: &str,
) -> Result<Vec<String>> {
let config = repo.config()?;
let mut gone = Vec::new();
for branch_result in repo.branches(Some(BranchType::Local))? {
let (branch, _) = branch_result?;
let Some(name) = branch.name()? else {
continue;
};
let name = name.to_string();
if name == current_branch {
continue;
}
let remote_key = format!("branch.{}.remote", name);
let Ok(remote) = config.get_string(&remote_key) else {
continue;
};
let merge_key = format!("branch.{}.merge", name);
let Ok(merge) = config.get_string(&merge_key) else {
continue;
};
let branch_part = merge.strip_prefix("refs/heads/").unwrap_or(&merge);
let tracking_ref = format!("refs/remotes/{}/{}", remote, branch_part);
if repo.find_reference(&tracking_ref).is_err() {
gone.push(name);
}
}
Ok(gone)
}
#[cfg(test)]
#[path = "update_test.rs"]
mod tests;