1use super::helpers;
39use crate::error::{GwError, Result};
40use crate::git;
41use crate::github::{self, PrState};
42use crate::output;
43use crate::state::{RepoType, SyncState, WorkingDirState};
44
45struct Plan {
47 new_base: String,
49 boundary: Option<String>,
52 retarget_pr: Option<u64>,
55 unstack: bool,
57 rerecord_base: bool,
60}
61
62pub fn run(verbose: bool) -> Result<()> {
64 if !git::is_git_repo() {
66 return Err(GwError::NotAGitRepository);
67 }
68
69 let working_dir = WorkingDirState::detect();
70 if !working_dir.is_clean() {
71 output::error(&format!(
72 "You have uncommitted changes ({}).",
73 working_dir.description()
74 ));
75 output::action("git add <files> && git commit -m \"...\" # commit first");
76 output::action("gw pause # or park the work as WIP");
77 return Err(GwError::UncommittedChanges);
78 }
79
80 let repo_type = RepoType::detect()?;
82 let home_branch = repo_type.home_branch();
83 let current = git::current_branch()?;
84
85 if current == home_branch {
87 println!();
88 output::info(&format!("Branch: {}", output::bold(¤t)));
89
90 output::info("Fetching from origin...");
92 git::fetch_prune(verbose)?;
93 output::success("Fetched (stale remote branches pruned)");
94
95 let default_remote = git::get_default_remote_branch()?;
97 let default_branch = default_remote.strip_prefix("origin/").unwrap_or("main");
98 helpers::pull_with_output(&default_remote, default_branch, verbose)?;
99
100 output::ready("Ready", home_branch);
101 return Ok(());
102 }
103
104 println!();
105 output::info(&format!("Branch: {}", output::bold(¤t)));
106
107 output::info("Fetching from origin...");
109 git::fetch_prune(verbose)?;
110
111 let pr = if github::is_gh_available() {
116 match github::get_pr_for_branch(¤t) {
117 Ok(pr) => pr,
118 Err(e) => {
119 output::warn(&format!("Could not fetch PR info: {e}"));
120 output::warn("Assuming no PR; syncing with the locally known base.");
121 None
122 }
123 }
124 } else {
125 output::warn("GitHub CLI (gh) not available; syncing with the locally known base.");
126 None
127 };
128
129 let default_remote = git::get_default_remote_branch()?;
131 let default_branch = default_remote.strip_prefix("origin/").unwrap_or("main");
132
133 let recorded_base = git::branch_base(¤t).filter(|b| b != default_branch && b != ¤t);
135 let recorded_base_sha = recorded_base
136 .as_ref()
137 .and_then(|_| git::branch_base_sha(¤t))
138 .filter(|sha| git::is_ancestor(sha, "HEAD"));
140
141 let plan = match pr {
144 Some(pr) => {
145 output::info(&format!("PR: #{} ({})", pr.number, pr.title));
146 output::info(&format!("Base: {}", pr.base_branch));
147 match &pr.state {
148 PrState::Merged { .. } => {
149 output::success(&format!("PR #{} is merged. Nothing to sync.", pr.number));
150 output::hints(&["gw cleanup # Delete the merged branch"]);
151 return Ok(());
152 }
153 PrState::Closed => {
154 output::warn(&format!(
155 "PR #{} was closed without merging. Nothing to sync.",
156 pr.number
157 ));
158 output::hints(&[&format!("gh pr reopen {} # Reopen it first", pr.number)]);
159 return Ok(());
160 }
161 PrState::Open => {}
162 }
163
164 if pr.base_branch == default_branch {
165 match retargeted_boundary(recorded_base.as_deref(), recorded_base_sha.as_deref()) {
170 Some(boundary) => Plan {
171 new_base: default_remote.clone(),
172 boundary: Some(boundary),
173 retarget_pr: None,
174 unstack: true,
175 rerecord_base: false,
176 },
177 None => Plan {
178 new_base: default_remote.clone(),
179 boundary: None,
180 retarget_pr: None,
181 unstack: false,
182 rerecord_base: false,
183 },
184 }
185 } else {
186 match plan_for_stacked_pr(
187 &pr.base_branch,
188 pr.number,
189 &default_remote,
190 recorded_base_sha.as_deref(),
191 )? {
192 Some(plan) => plan,
193 None => return Ok(()),
194 }
195 }
196 }
197 None => match &recorded_base {
198 Some(base) => {
199 match plan_for_recorded_base(base, &default_remote, recorded_base_sha.as_deref())? {
200 Some(plan) => plan,
201 None => return Ok(()),
202 }
203 }
204 None => Plan {
205 new_base: default_remote.clone(),
206 boundary: None,
207 retarget_pr: None,
208 unstack: false,
209 rerecord_base: false,
210 },
211 },
212 };
213
214 execute(&plan, ¤t, default_branch, verbose)
216}
217
218fn retargeted_boundary(
223 recorded_base: Option<&str>,
224 recorded_base_sha: Option<&str>,
225) -> Option<String> {
226 let base = recorded_base?;
227 if !recorded_base_pr_merged(base) {
228 return None;
229 }
230 output::info(&format!(
231 "Recorded base '{}' merged and the PR now targets the default branch — restacking",
232 base
233 ));
234 boundary_for_merged_base(base, recorded_base_sha)
235}
236
237fn recorded_base_pr_merged(base: &str) -> bool {
241 if !github::is_gh_available() {
242 return false;
243 }
244 match github::get_pr_for_branch(base) {
245 Ok(Some(base_pr)) => base_pr.state.is_merged(),
246 Ok(None) => false,
247 Err(e) => {
248 output::warn(&format!("Could not check PR for base '{}': {}", base, e));
249 false
250 }
251 }
252}
253
254fn boundary_for_merged_base(base: &str, recorded_base_sha: Option<&str>) -> Option<String> {
258 if let Some(sha) = recorded_base_sha {
259 return Some(sha.to_string());
260 }
261 let remote_ref = format!("origin/{base}");
262 if git::ref_exists(&remote_ref) {
263 return Some(remote_ref);
264 }
265 output::warn(&format!(
266 "Cannot find where '{}' was forked from (no recorded base tip, origin/{} is gone).",
267 base, base
268 ));
269 output::hints(&[&format!(
270 "git rebase --onto origin/main <last commit of {base}> # replay only your commits"
271 )]);
272 None
273}
274
275fn plan_for_stacked_pr(
277 base: &str,
278 pr_number: u64,
279 default_remote: &str,
280 recorded_base_sha: Option<&str>,
281) -> Result<Option<Plan>> {
282 let base_pr = github::get_pr_for_branch(base)?;
283 match base_pr.as_ref().map(|p| &p.state) {
284 Some(PrState::Merged { .. }) => {
285 let base_pr = base_pr.as_ref().expect("matched Some");
286 output::success(&format!(
287 "Base PR #{} ({}) is merged ✓",
288 base_pr.number, base
289 ));
290 Ok(
291 boundary_for_merged_base(base, recorded_base_sha).map(|boundary| Plan {
292 new_base: default_remote.to_string(),
293 boundary: Some(boundary),
294 retarget_pr: Some(pr_number),
295 unstack: true,
296 rerecord_base: false,
297 }),
298 )
299 }
300 Some(PrState::Closed) => {
301 let base_pr = base_pr.as_ref().expect("matched Some");
302 output::warn(&format!(
303 "Base PR #{} ({}) was closed without merging.",
304 base_pr.number, base
305 ));
306 output::hints(&[&format!(
307 "gh pr reopen {} # or retarget this PR with gh pr edit --base",
308 base_pr.number
309 )]);
310 Ok(None)
311 }
312 Some(PrState::Open) | None => {
313 if base_pr.is_none() {
315 output::warn(&format!(
316 "No PR found for base branch '{}'; following origin/{}.",
317 base, base
318 ));
319 }
320 let base_ref = format!("origin/{base}");
321 if !git::ref_exists(&base_ref) {
322 output::warn(&format!("origin/{} does not exist. Nothing to sync.", base));
323 return Ok(None);
324 }
325 Ok(Some(Plan {
326 new_base: base_ref,
327 boundary: recorded_base_sha.map(String::from),
328 retarget_pr: None,
329 unstack: false,
330 rerecord_base: recorded_base_sha.is_some(),
331 }))
332 }
333 }
334}
335
336fn plan_for_recorded_base(
338 base: &str,
339 default_remote: &str,
340 recorded_base_sha: Option<&str>,
341) -> Result<Option<Plan>> {
342 output::info(&format!("Base: {} (stacked, PR not created yet)", base));
343 if recorded_base_pr_merged(base) {
344 output::success(&format!("Base '{}' merged ✓ — restacking onto main", base));
345 return Ok(
346 boundary_for_merged_base(base, recorded_base_sha).map(|boundary| Plan {
347 new_base: default_remote.to_string(),
348 boundary: Some(boundary),
349 retarget_pr: None,
350 unstack: true,
351 rerecord_base: false,
352 }),
353 );
354 }
355 let remote_ref = format!("origin/{base}");
357 let base_ref = if git::ref_exists(&remote_ref) {
358 remote_ref
359 } else if git::branch_exists(base) {
360 base.to_string()
361 } else {
362 output::warn(&format!(
363 "Base branch '{}' no longer exists locally or on origin. Nothing to sync.",
364 base
365 ));
366 return Ok(None);
367 };
368 Ok(Some(Plan {
369 new_base: base_ref,
370 boundary: recorded_base_sha.map(String::from),
371 retarget_pr: None,
372 unstack: false,
373 rerecord_base: recorded_base_sha.is_some(),
374 }))
375}
376
377fn execute(plan: &Plan, current: &str, default_branch: &str, verbose: bool) -> Result<()> {
379 let upstream_exists = git::has_remote_tracking(current);
380
381 println!();
382 if git::is_ancestor(&plan.new_base, "HEAD") && plan.retarget_pr.is_none() {
383 output::success(&format!("Already up to date with {}", plan.new_base));
384 if upstream_exists && matches!(SyncState::detect(current), Ok(SyncState::Diverged { .. })) {
386 output::info("Local history was rewritten but not pushed — publishing...");
387 git::force_push_with_lease(current, verbose)?;
388 output::success("Force pushed");
389 }
390 if plan.unstack {
391 git::unset_branch_base(current, verbose)?;
392 }
393 output::ready("Synced", current);
394 return Ok(());
395 }
396
397 let behind = git::behind_base_count("HEAD", &plan.new_base);
398 output::info("Syncing...");
399
400 let result = match &plan.boundary {
404 Some(boundary) => {
405 output::info(&format!(
406 " Rebasing commits after {} onto {} ({} new commit(s))...",
407 short(boundary),
408 plan.new_base,
409 behind
410 ));
411 git::rebase_onto(&plan.new_base, boundary, verbose)
412 }
413 None => {
414 output::info(&format!(
415 " Rebasing onto {} ({} new commit(s))...",
416 plan.new_base, behind
417 ));
418 git::rebase(&plan.new_base, verbose)
419 }
420 };
421 if let Err(e) = result {
422 output::error("Rebase failed. You may need to resolve conflicts manually.");
423 output::action("git rebase --continue # After resolving conflicts, then: gw sync");
424 output::action("git rebase --abort # To cancel");
425 return Err(e);
426 }
427
428 if let Some(pr_number) = plan.retarget_pr {
429 output::info(&format!(" Updating PR base to {}...", default_branch));
430 github::update_pr_base(pr_number, default_branch)?;
431 }
432
433 if upstream_exists {
434 output::info(" Force pushing...");
435 git::force_push_with_lease(current, verbose)?;
436 }
437
438 if plan.unstack {
439 git::unset_branch_base(current, verbose)?;
443 } else if plan.rerecord_base {
444 if let Ok(sha) = git::rev_parse(&plan.new_base) {
447 git::set_branch_base_sha(current, &sha, verbose)?;
448 }
449 }
450
451 println!();
452 output::ready("Synced", current);
453 let mut hints: Vec<String> = Vec::new();
454 if let Some(pr_number) = plan.retarget_pr {
455 hints.push(format!(
456 "PR #{} base is now '{}'",
457 pr_number, default_branch
458 ));
459 }
460 if !upstream_exists {
461 hints.push(format!(
462 "git push -u origin {current} # Publish when ready"
463 ));
464 }
465 hints.push("gw status # Check status".to_string());
466 let hint_refs: Vec<&str> = hints.iter().map(String::as_str).collect();
467 output::hints(&hint_refs);
468
469 Ok(())
470}
471
472fn short(reference: &str) -> &str {
474 if reference.len() == 40 && reference.chars().all(|c| c.is_ascii_hexdigit()) {
475 &reference[..7]
476 } else {
477 reference
478 }
479}