Skip to content

Commit 0215299

Browse files
committed
squash restack
1 parent 388f740 commit 0215299

File tree

2 files changed

+293
-2
lines changed

2 files changed

+293
-2
lines changed

src/main.rs

Lines changed: 257 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -148,6 +148,15 @@ enum Command {
148148
/// Restack all parent branches recursively up to trunk.
149149
#[arg(long, short = 'a', default_value_t = false)]
150150
all_parents: bool,
151+
/// Squash all commits in the branch into a single commit.
152+
#[arg(long, short = 's', default_value_t = false)]
153+
squash: bool,
154+
/// Continue a previously interrupted squash operation after conflict resolution.
155+
#[arg(long, default_value_t = false)]
156+
r#continue: bool,
157+
/// Abort an in-progress squash operation and restore the original branch state.
158+
#[arg(long, default_value_t = false)]
159+
abort: bool,
151160
},
152161
/// Shows the log between the given branch and its parent (git-stack tree) branch.
153162
Log {
@@ -358,6 +367,23 @@ fn inner_main() -> Result<()> {
358367
let current_upstream = git_repo.get_upstream("");
359368
tracing::debug!(run_version, current_branch, current_upstream);
360369

370+
// Check for pending squash operation - block other commands except --continue and --abort
371+
if state.has_pending_squash(&repo) {
372+
match &args.command {
373+
Some(Command::Restack {
374+
r#continue: true, ..
375+
}) => { /* allowed */ }
376+
Some(Command::Restack { abort: true, .. }) => { /* allowed */ }
377+
_ => {
378+
bail!(
379+
"A squash operation is in progress for this repository.\n\
380+
Run `git stack restack --continue` after resolving conflicts,\n\
381+
or `git stack restack --abort` to cancel."
382+
);
383+
}
384+
}
385+
}
386+
361387
match args.command {
362388
Some(Command::Checkout { branch_name }) => state.checkout(
363389
&git_repo,
@@ -372,7 +398,18 @@ fn inner_main() -> Result<()> {
372398
fetch,
373399
push,
374400
all_parents,
401+
squash,
402+
r#continue,
403+
abort,
375404
}) => {
405+
// Handle --continue first
406+
if r#continue {
407+
return handle_squash_continue(&git_repo, &mut state, &repo);
408+
}
409+
// Handle --abort
410+
if abort {
411+
return handle_squash_abort(&git_repo, &mut state, &repo);
412+
}
376413
let restack_branch = branch.clone().unwrap_or_else(|| current_branch.clone());
377414
state.try_auto_mount(&git_repo, &repo, &restack_branch)?;
378415
restack(
@@ -385,6 +422,7 @@ fn inner_main() -> Result<()> {
385422
fetch,
386423
push,
387424
all_parents,
425+
squash,
388426
)
389427
}
390428
Some(Command::Mount { parent_branch }) => {
@@ -1005,6 +1043,202 @@ fn status(
10051043
Ok(())
10061044
}
10071045

1046+
/// Get concatenated commit messages between ancestor and branch tip.
1047+
fn get_concatenated_commit_messages(branch: &str, ancestor: &str) -> Result<String> {
1048+
let output = run_git(&[
1049+
"log",
1050+
"--reverse",
1051+
"--format=%B",
1052+
&format!("{}..{}", ancestor, branch),
1053+
])?;
1054+
1055+
let messages = output.output().unwrap_or_default();
1056+
if messages.trim().is_empty() {
1057+
bail!("No commits found between {} and {}", ancestor, branch);
1058+
}
1059+
1060+
// Clean up the messages - join with separator for readability
1061+
let cleaned: String = messages
1062+
.split("\n\n")
1063+
.filter(|s| !s.trim().is_empty())
1064+
.collect::<Vec<_>>()
1065+
.join("\n\n");
1066+
1067+
Ok(cleaned)
1068+
}
1069+
1070+
/// Complete a squash operation (either after clean merge or after conflict resolution).
1071+
fn complete_squash(
1072+
git_repo: &GitRepo,
1073+
state: &mut State,
1074+
repo: &str,
1075+
pending: &state::PendingSquashOperation,
1076+
) -> Result<()> {
1077+
// Commit with the concatenated messages
1078+
run_git(&["commit", "-m", &pending.squash_message])?;
1079+
1080+
// Move the branch pointer: git checkout -B <branch>
1081+
// This points <branch> to current HEAD (the squashed commit) and checks it out
1082+
run_git(&["checkout", "-B", &pending.branch_name])?;
1083+
1084+
// Clean up temp branch
1085+
let _ = run_git(&["branch", "-D", &pending.tmp_branch_name]);
1086+
1087+
// Clear the pending operation
1088+
state.set_pending_squash(repo, None);
1089+
state.save_state()?;
1090+
1091+
println!(
1092+
"Squash completed for branch '{}'.",
1093+
pending.branch_name.yellow()
1094+
);
1095+
1096+
Ok(())
1097+
}
1098+
1099+
/// Execute a squash operation for a single branch.
1100+
fn squash_branch(
1101+
git_repo: &GitRepo,
1102+
state: &mut State,
1103+
repo: &str,
1104+
branch: &state::Branch,
1105+
parent: &str,
1106+
) -> Result<bool> {
1107+
let branch_name = &branch.name;
1108+
let tmp_branch = format!("tmp-{}", branch_name);
1109+
1110+
// Determine ancestor for commit message range
1111+
let source_sha = git_repo.sha(branch_name)?;
1112+
let lkg_ancestor = branch
1113+
.lkg_parent
1114+
.as_deref()
1115+
.filter(|lkg| git_repo.is_ancestor(lkg, &source_sha).unwrap_or(false))
1116+
.map(|s| s.to_string())
1117+
.or_else(|| git_repo.merge_base(parent, branch_name).ok())
1118+
.ok_or_else(|| anyhow!("Cannot determine ancestor for commit messages"))?;
1119+
1120+
// Collect commit messages from ancestor to branch tip
1121+
let squash_message = get_concatenated_commit_messages(branch_name, &lkg_ancestor)?;
1122+
1123+
// Save original SHA for recovery
1124+
let original_sha = git_repo.sha(branch_name)?;
1125+
1126+
// Create pending operation state
1127+
let pending = state::PendingSquashOperation {
1128+
branch_name: branch_name.clone(),
1129+
parent_branch: parent.to_string(),
1130+
tmp_branch_name: tmp_branch.clone(),
1131+
original_sha,
1132+
squash_message: squash_message.clone(),
1133+
};
1134+
state.set_pending_squash(repo, Some(pending.clone()));
1135+
state.save_state()?;
1136+
1137+
// Execute the squash workflow
1138+
// git checkout <parent>
1139+
run_git(&["checkout", parent])?;
1140+
1141+
// git checkout -B tmp-<branch>
1142+
run_git(&["checkout", "-B", &tmp_branch])?;
1143+
1144+
// git merge --squash <branch>
1145+
let merge_status = run_git_status(&["merge", "--squash", branch_name], None)?;
1146+
1147+
if !merge_status.success() {
1148+
// Conflict! Print instructions and exit
1149+
eprintln!("{}", "Merge conflict during squash operation.".red().bold());
1150+
eprintln!();
1151+
eprintln!("Resolve the conflicts, then run:");
1152+
eprintln!(" git add <resolved-files>");
1153+
eprintln!(" {}", "git stack restack --continue".green().bold());
1154+
eprintln!();
1155+
eprintln!("Or to abort and restore the original branch:");
1156+
eprintln!(" {}", "git stack restack --abort".yellow().bold());
1157+
std::process::exit(1);
1158+
}
1159+
1160+
// Complete the squash (no conflict)
1161+
complete_squash(git_repo, state, repo, &pending)?;
1162+
1163+
Ok(true)
1164+
}
1165+
1166+
/// Handle the --continue flag for resuming a squash operation.
1167+
fn handle_squash_continue(
1168+
git_repo: &GitRepo,
1169+
state: &mut State,
1170+
repo: &str,
1171+
) -> Result<()> {
1172+
let pending = state
1173+
.get_pending_squash(repo)
1174+
.ok_or_else(|| anyhow!("No pending squash operation to continue."))?
1175+
.clone();
1176+
1177+
// Check if there are unresolved conflicts
1178+
let status_output = run_git(&["status", "--porcelain"])?;
1179+
let has_conflicts = status_output
1180+
.output()
1181+
.map(|s| s.lines().any(|line| line.starts_with("UU") || line.starts_with("AA")))
1182+
.unwrap_or(false);
1183+
1184+
if has_conflicts {
1185+
bail!(
1186+
"There are still unresolved conflicts. Resolve them and add the files, \
1187+
then run --continue again."
1188+
);
1189+
}
1190+
1191+
// Check if we're on the temp branch
1192+
let current = git_repo.current_branch()?;
1193+
if current != pending.tmp_branch_name {
1194+
bail!(
1195+
"Expected to be on branch '{}' but currently on '{}'. \
1196+
Please checkout '{}' and run --continue again.",
1197+
pending.tmp_branch_name,
1198+
current,
1199+
pending.tmp_branch_name
1200+
);
1201+
}
1202+
1203+
// Complete the squash
1204+
complete_squash(git_repo, state, repo, &pending)?;
1205+
1206+
Ok(())
1207+
}
1208+
1209+
/// Handle the --abort flag for aborting a squash operation.
1210+
fn handle_squash_abort(
1211+
git_repo: &GitRepo,
1212+
state: &mut State,
1213+
repo: &str,
1214+
) -> Result<()> {
1215+
let pending = state
1216+
.get_pending_squash(repo)
1217+
.ok_or_else(|| anyhow!("No pending squash operation to abort."))?
1218+
.clone();
1219+
1220+
// Abort any in-progress merge
1221+
let _ = run_git_status(&["merge", "--abort"], None);
1222+
1223+
// Checkout the original branch at its original SHA
1224+
run_git(&["checkout", "-f", &pending.original_sha])?;
1225+
run_git(&["checkout", "-B", &pending.branch_name])?;
1226+
1227+
// Clean up temp branch if it exists
1228+
let _ = run_git(&["branch", "-D", &pending.tmp_branch_name]);
1229+
1230+
// Clear pending state
1231+
state.set_pending_squash(repo, None);
1232+
state.save_state()?;
1233+
1234+
println!(
1235+
"Squash operation aborted. Branch '{}' restored to original state.",
1236+
pending.branch_name.yellow()
1237+
);
1238+
1239+
Ok(())
1240+
}
1241+
10081242
#[allow(clippy::too_many_arguments)]
10091243
fn restack(
10101244
git_repo: &GitRepo,
@@ -1016,6 +1250,7 @@ fn restack(
10161250
fetch: bool,
10171251
push: bool,
10181252
all_parents: bool,
1253+
squash: bool,
10191254
) -> Result<(), anyhow::Error> {
10201255
let restack_branch = restack_branch.unwrap_or(orig_branch.clone());
10211256

@@ -1053,13 +1288,19 @@ fn restack(
10531288
// Find starting_branch in the stacks of branches to determine which stack to use.
10541289
let plan = state.plan_restack(git_repo, repo, &restack_branch, all_parents)?;
10551290

1056-
tracing::debug!(?plan, "Restacking branches with plan. Checking out main...");
1291+
// Collect plan into owned data to allow mutable access to state during the loop
1292+
let plan_owned: Vec<(String, state::Branch)> = plan
1293+
.into_iter()
1294+
.map(|step| (step.parent, step.branch.clone()))
1295+
.collect();
1296+
1297+
tracing::debug!("Restacking branches with plan. Checking out main...");
10571298
git_checkout_main(git_repo, None)?;
10581299

10591300
// Track pushed branches to record SHAs after the loop (avoids borrow issues with plan)
10601301
let mut pushed_branches: Vec<String> = Vec::new();
10611302

1062-
for RestackStep { parent, branch } in plan {
1303+
for (parent, branch) in plan_owned {
10631304
// Ensure the branch exists locally (check it out from remote if needed)
10641305
if !git_repo.branch_exists(&branch.name) {
10651306
let remote_ref = format!("{DEFAULT_REMOTE}/{}", branch.name);
@@ -1078,6 +1319,20 @@ fn restack(
10781319
);
10791320
let source = git_repo.sha(&branch.name)?;
10801321

1322+
// Handle squash mode - squash all commits into one on top of parent
1323+
if squash {
1324+
squash_branch(git_repo, &mut state, repo, &branch, &parent)?;
1325+
let status = if push {
1326+
git_push(git_repo, &branch.name)?;
1327+
pushed_branches.push(branch.name.clone());
1328+
"squashed, pushed"
1329+
} else {
1330+
"squashed"
1331+
};
1332+
branch_results.push((branch.name.clone(), status.to_string()));
1333+
continue;
1334+
}
1335+
10811336
if git_repo.is_ancestor(&parent, &branch.name)? {
10821337
tracing::debug!(
10831338
"Branch '{}' is already stacked on '{}'.",

src/state.rs

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,21 @@ pub enum StackMethod {
4646
Merge,
4747
}
4848

49+
/// State for an in-progress squash operation that needs to be resumed after conflict resolution.
50+
#[derive(Debug, Clone, Serialize, Deserialize)]
51+
pub struct PendingSquashOperation {
52+
/// The branch being squashed.
53+
pub branch_name: String,
54+
/// The parent branch we're squashing onto.
55+
pub parent_branch: String,
56+
/// The temporary branch name used during the squash (e.g., "tmp-<branch>").
57+
pub tmp_branch_name: String,
58+
/// The original branch SHA before squashing (for recovery).
59+
pub original_sha: String,
60+
/// The concatenated commit messages to use for the squash commit.
61+
pub squash_message: String,
62+
}
63+
4964
#[derive(Debug, Clone, Serialize, Deserialize)]
5065
pub struct Branch {
5166
/// The name of the branch or ref.
@@ -90,13 +105,17 @@ pub struct RepoState {
90105
/// branch can be deleted (no unpushed work would be lost).
91106
#[serde(default, skip_serializing_if = "HashSet::is_empty")]
92107
pub seen_remote_shas: HashSet<String>,
108+
/// Pending squash operation that needs to be resumed after conflict resolution.
109+
#[serde(default, skip_serializing_if = "Option::is_none")]
110+
pub pending_squash: Option<PendingSquashOperation>,
93111
}
94112

95113
impl RepoState {
96114
pub fn new(tree: Branch) -> Self {
97115
Self {
98116
tree,
99117
seen_remote_shas: HashSet::new(),
118+
pending_squash: None,
100119
}
101120
}
102121
}
@@ -174,6 +193,23 @@ impl State {
174193
repo_state.seen_remote_shas.clear();
175194
}
176195
}
196+
/// Get the pending squash operation for a repo, if any.
197+
pub fn get_pending_squash(&self, repo: &str) -> Option<&PendingSquashOperation> {
198+
self.repos.get(repo).and_then(|r| r.pending_squash.as_ref())
199+
}
200+
/// Set or clear the pending squash operation for a repo.
201+
pub fn set_pending_squash(&mut self, repo: &str, pending: Option<PendingSquashOperation>) {
202+
if let Some(repo_state) = self.repos.get_mut(repo) {
203+
repo_state.pending_squash = pending;
204+
}
205+
}
206+
/// Check if there is a pending squash operation for a repo.
207+
pub fn has_pending_squash(&self, repo: &str) -> bool {
208+
self.repos
209+
.get(repo)
210+
.map(|r| r.pending_squash.is_some())
211+
.unwrap_or(false)
212+
}
177213
/// If there is an existing git-stack branch with the same name, check it out. If there isn't,
178214
/// then check whether the branch exists in the git repo. If it does, then let the user know
179215
/// that they need to use `git checkout` to check it out. If it doesn't, then create a new

0 commit comments

Comments
 (0)