Skip to content

Commit a89a984

Browse files
committed
make sync safer by requiring confirmation if it has remote changes
1 parent ea8508e commit a89a984

File tree

4 files changed

+93
-42
lines changed

4 files changed

+93
-42
lines changed

src/git.rs

Lines changed: 5 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -188,7 +188,7 @@ pub(crate) fn git_checkout_main(repo: &GitRepo, new_branch: Option<&str>) -> Res
188188
}
189189
git_fetch()?;
190190
let remote = DEFAULT_REMOTE;
191-
let trunk = git_trunk(repo)?;
191+
let trunk = git_trunk(repo).ok_or_else(|| anyhow!("No remote configured"))?;
192192

193193
// Check that we don't orphan unpushed changes in the local `main` branch.
194194
if !repo.is_ancestor(&trunk.main_branch, &trunk.remote_main)? {
@@ -228,12 +228,10 @@ pub(crate) struct GitTrunk {
228228
pub(crate) main_branch: String,
229229
}
230230

231-
pub(crate) fn git_trunk(git_repo: &GitRepo) -> Result<GitTrunk> {
232-
let remote_main = git_repo.remote_main(DEFAULT_REMOTE)?;
233-
let main_branch = after_text(&remote_main, format!("{DEFAULT_REMOTE}/"))
234-
.ok_or(anyhow!("no branch?"))?
235-
.to_string();
236-
Ok(GitTrunk {
231+
pub(crate) fn git_trunk(git_repo: &GitRepo) -> Option<GitTrunk> {
232+
let remote_main = git_repo.remote_main(DEFAULT_REMOTE).ok()?;
233+
let main_branch = after_text(&remote_main, format!("{DEFAULT_REMOTE}/"))?.to_string();
234+
Some(GitTrunk {
237235
remote_main,
238236
main_branch,
239237
})

src/main.rs

Lines changed: 19 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -968,8 +968,8 @@ fn status(
968968
if fetch {
969969
git_fetch()?;
970970
}
971-
// ensure_trunk creates the tree if it doesn't exist
972-
let _trunk = state.ensure_trunk(git_repo, repo)?;
971+
// ensure_trunk creates the tree if it doesn't exist (no-op if no remote)
972+
let _trunk = state.ensure_trunk(git_repo, repo);
973973

974974
// Auto-cleanup any missing branches before displaying the tree
975975
state.auto_cleanup_missing_branches(git_repo, repo)?;
@@ -980,9 +980,10 @@ fn status(
980980
// Load display_authors for filtering (show other authors dimmed)
981981
let display_authors = github::load_display_authors();
982982

983-
let tree = state
984-
.get_tree_mut(repo)
985-
.expect("tree exists after ensure_trunk");
983+
let Some(tree) = state.get_tree_mut(repo) else {
984+
println!("No stack configured for this repository.");
985+
return Ok(());
986+
};
986987
recur_tree(
987988
git_repo,
988989
tree,
@@ -1029,7 +1030,7 @@ fn restack(
10291030
}
10301031

10311032
// Check if user is trying to restack the trunk branch
1032-
let trunk = git_trunk(git_repo)?;
1033+
let trunk = git_trunk(git_repo).ok_or_else(|| anyhow!("No remote configured"))?;
10331034
if restack_branch == trunk.main_branch {
10341035
println!(
10351036
"You are on the trunk branch ({}). Nothing to restack.",
@@ -1290,7 +1291,8 @@ fn sync_pr_bases_after_restack(git_repo: &GitRepo, state: &State, repo: &str) ->
12901291
.get_tree(repo)
12911292
.ok_or_else(|| anyhow!("No stack tree found"))?;
12921293

1293-
let trunk = crate::git::git_trunk(git_repo)?;
1294+
let trunk =
1295+
crate::git::git_trunk(git_repo).ok_or_else(|| anyhow!("No remote configured"))?;
12941296

12951297
// Collect branches with depth for bottom-up processing
12961298
let branches_with_depth = collect_branches_with_depth(tree, &tree.name, 0);
@@ -1516,10 +1518,11 @@ fn handle_import_command(
15161518
}
15171519

15181520
let client = GitHubClient::from_env(&repo_id)?;
1519-
let trunk = crate::git::git_trunk(git_repo)?;
1521+
let trunk =
1522+
crate::git::git_trunk(git_repo).ok_or_else(|| anyhow!("No remote configured"))?;
15201523

15211524
// Ensure trunk exists in tree
1522-
state.ensure_trunk(git_repo, repo)?;
1525+
state.ensure_trunk(git_repo, repo);
15231526

15241527
if import_all {
15251528
// Import all open PRs
@@ -1542,7 +1545,9 @@ fn handle_import_command(
15421545

15431546
// Show the tree
15441547
println!();
1545-
let tree = state.get_tree(repo).expect("tree exists after import");
1548+
let Some(tree) = state.get_tree(repo) else {
1549+
return Ok(());
1550+
};
15461551
let pr_cache = fetch_pr_cache(git_repo);
15471552
let display_authors = github::load_display_authors();
15481553
recur_tree(
@@ -1819,7 +1824,8 @@ fn handle_pr_command(
18191824
state.try_auto_mount(git_repo, repo, &branch_name)?;
18201825

18211826
// Check if this is the trunk branch (can't create PR for main)
1822-
let trunk = crate::git::git_trunk(git_repo)?;
1827+
let trunk = crate::git::git_trunk(git_repo)
1828+
.ok_or_else(|| anyhow!("No remote configured"))?;
18231829
if branch_name == trunk.main_branch {
18241830
bail!(
18251831
"Cannot create a PR for the trunk branch '{}'.",
@@ -1991,7 +1997,8 @@ fn handle_pr_command(
19911997
PrAction::Sync { all, dry_run } => {
19921998
use github::UpdatePrRequest;
19931999

1994-
let trunk = crate::git::git_trunk(git_repo)?;
2000+
let trunk = crate::git::git_trunk(git_repo)
2001+
.ok_or_else(|| anyhow!("No remote configured"))?;
19952002

19962003
// Get branches to sync with depth for bottom-up processing
19972004
let branches_to_sync: Vec<(String, String, usize)> = if all {

src/state.rs

Lines changed: 33 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -189,13 +189,14 @@ impl State {
189189
current_upstream: Option<String>,
190190
branch_name: String,
191191
) -> Result<()> {
192-
let trunk = git_trunk(git_repo)?;
193192
// Ensure the main branch is in the git-stack tree for this repo if we haven't
194-
// added it yet.
195-
self.repos
196-
.entry(repo.to_string())
197-
.or_insert_with(|| RepoState::new(Branch::new(trunk.main_branch.clone(), None)));
198-
self.save_state()?;
193+
// added it yet (only if we have a remote configured).
194+
if let Some(trunk) = git_trunk(git_repo) {
195+
self.repos
196+
.entry(repo.to_string())
197+
.or_insert_with(|| RepoState::new(Branch::new(trunk.main_branch.clone(), None)));
198+
self.save_state()?;
199+
}
199200

200201
let branch_exists_in_tree = self.branch_exists_in_tree(repo, &branch_name);
201202
let branch_exists_locally = git_branch_exists(git_repo, &branch_name);
@@ -574,15 +575,15 @@ impl State {
574575
Ok(())
575576
}
576577

577-
pub(crate) fn ensure_trunk(&mut self, git_repo: &GitRepo, repo: &str) -> Result<GitTrunk> {
578+
pub(crate) fn ensure_trunk(&mut self, git_repo: &GitRepo, repo: &str) -> Option<GitTrunk> {
578579
let trunk = git_trunk(git_repo)?;
579580
// The branch might not exist in git, let's create it, and add it to the tree.
580581
// Ensure the main branch is in the git-stack tree for this repo if we haven't
581582
// added it yet.
582583
self.repos
583584
.entry(repo.to_string())
584585
.or_insert_with(|| RepoState::new(Branch::new(trunk.main_branch.clone(), None)));
585-
Ok(trunk)
586+
Some(trunk)
586587
}
587588

588589
pub(crate) fn mount(
@@ -592,16 +593,20 @@ impl State {
592593
branch_name: &str,
593594
parent_branch: Option<String>,
594595
) -> Result<()> {
595-
let trunk = self.ensure_trunk(git_repo, repo)?;
596+
let trunk = self.ensure_trunk(git_repo, repo);
596597

597-
if trunk.main_branch == branch_name {
598-
bail!(
599-
"Branch {branch_name} cannot be stacked on anything else.",
600-
branch_name = branch_name.red()
601-
);
598+
if let Some(ref trunk) = trunk {
599+
if trunk.main_branch == branch_name {
600+
bail!(
601+
"Branch {branch_name} cannot be stacked on anything else.",
602+
branch_name = branch_name.red()
603+
);
604+
}
602605
}
603606

604-
let parent_branch = parent_branch.unwrap_or(trunk.main_branch);
607+
let parent_branch = parent_branch
608+
.or_else(|| trunk.map(|t| t.main_branch))
609+
.ok_or_else(|| anyhow!("No parent branch specified and no remote configured"))?;
605610

606611
if branch_name == parent_branch {
607612
bail!(
@@ -678,7 +683,9 @@ impl State {
678683
// For each sub-branch in the tree, check if the parent
679684

680685
tracing::debug!("Refreshing lkgs for all branches...");
681-
let trunk = git_trunk(git_repo)?;
686+
let Some(trunk) = git_trunk(git_repo) else {
687+
return Ok(());
688+
};
682689

683690
let mut parent_lkgs: HashMap<String, Option<String>> = HashMap::default();
684691

@@ -800,8 +807,8 @@ impl State {
800807
repo: &str,
801808
branch_name: &str,
802809
) -> Result<bool> {
803-
// Ensure the tree exists for this repo
804-
self.ensure_trunk(git_repo, repo)?;
810+
// Ensure the tree exists for this repo (no-op if no remote)
811+
self.ensure_trunk(git_repo, repo);
805812

806813
// Check if the branch is already in the tree
807814
if self.branch_exists_in_tree(repo, branch_name) {
@@ -813,8 +820,10 @@ impl State {
813820
bail!("Branch {branch_name} does not exist in git");
814821
}
815822

816-
// Get the tree for this repo (guaranteed to exist after ensure_trunk)
817-
let tree = self.get_tree(repo).expect("tree exists after ensure_trunk");
823+
// Get the tree for this repo (may not exist if no remote configured)
824+
let Some(tree) = self.get_tree(repo) else {
825+
return Ok(false);
826+
};
818827

819828
// Collect all mounted branches
820829
let mut all_branches = Vec::new();
@@ -831,7 +840,10 @@ impl State {
831840
// Determine the parent branch
832841
let parent_branch = if ancestor_branches.is_empty() {
833842
// No ancestors found, default to the trunk/main branch
834-
let trunk = git_trunk(git_repo)?;
843+
let Some(trunk) = git_trunk(git_repo) else {
844+
// No remote configured and no ancestor branches - can't auto-mount
845+
return Ok(false);
846+
};
835847
tracing::info!(
836848
"No mounted ancestor branches found for {}. Defaulting to trunk branch {}.",
837849
branch_name,

src/sync.rs

Lines changed: 36 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -195,6 +195,10 @@ impl SyncPlan {
195195
pub fn is_empty(&self) -> bool {
196196
self.local_changes.is_empty() && self.remote_changes.is_empty()
197197
}
198+
199+
pub fn has_remote_changes(&self) -> bool {
200+
!self.remote_changes.is_empty()
201+
}
198202
}
199203

200204
// ============== Sync Options ==============
@@ -338,7 +342,22 @@ pub fn sync(git_repo: &GitRepo, state: &mut State, repo: &str, options: SyncOpti
338342
"\n{}",
339343
"Dry run mode: no changes applied.".bright_blue().bold()
340344
);
345+
} else if plan.has_remote_changes() {
346+
// Prompt for confirmation before applying remote changes
347+
if !std::io::stdin().is_terminal() {
348+
bail!(
349+
"Remote changes require confirmation. Use --dry-run to preview or run interactively to confirm."
350+
);
351+
}
352+
if confirm_remote_changes() {
353+
println!("\nApplying changes...");
354+
apply_plan(git_repo, state, repo, &client, &repo_id, &plan)?;
355+
println!("\n{}", "Sync complete!".green().bold());
356+
} else {
357+
println!("\n{}", "Aborted.".yellow());
358+
}
341359
} else {
360+
// Only local changes - apply without confirmation
342361
println!("\nApplying changes...");
343362
apply_plan(git_repo, state, repo, &client, &repo_id, &plan)?;
344363
println!("\n{}", "Sync complete!".green().bold());
@@ -347,6 +366,21 @@ pub fn sync(git_repo: &GitRepo, state: &mut State, repo: &str, options: SyncOpti
347366
Ok(())
348367
}
349368

369+
/// Prompt user to confirm remote changes
370+
fn confirm_remote_changes() -> bool {
371+
use std::io::{self, Write};
372+
373+
print!("Apply remote changes? [y/N] ");
374+
io::stdout().flush().unwrap();
375+
376+
let mut input = String::new();
377+
if io::stdin().read_line(&mut input).is_err() {
378+
return false;
379+
}
380+
381+
matches!(input.trim().to_lowercase().as_str(), "y" | "yes")
382+
}
383+
350384
// ============== Stage 1: Read Functions ==============
351385

352386
/// Garbage collect seen SHAs that are no longer needed.
@@ -447,7 +481,7 @@ fn collect_tracked_branch_shas(git_repo: &GitRepo, branch: &Branch) -> Vec<Strin
447481

448482
/// Read current local state from git-stack and git
449483
fn read_local_state(git_repo: &GitRepo, state: &State, repo: &str) -> Result<LocalState> {
450-
let trunk = git_trunk(git_repo)?;
484+
let trunk = git_trunk(git_repo).ok_or_else(|| anyhow!("No remote configured"))?;
451485
let mut branches = HashMap::new();
452486

453487
// Get the tree for this repo
@@ -1141,7 +1175,7 @@ fn apply_plan(
11411175

11421176
if unmount_set.contains(current_branch.as_str()) {
11431177
// Find the first ancestor that's NOT being unmounted
1144-
let trunk = git_trunk(git_repo)?;
1178+
let trunk = git_trunk(git_repo).ok_or_else(|| anyhow!("No remote configured"))?;
11451179
let mut safe_branch = trunk.main_branch.clone();
11461180
let mut current = current_branch.clone();
11471181

0 commit comments

Comments
 (0)