Skip to content

Commit 17d7b8a

Browse files
authored
refactor(stack): extract shared git helpers into crate::git (#1555)
Every per-command stack module (drop, fixup, reword, reorder, move, squash, checkout, sync, list, note, edit, setup, new, push) maintained its own slight variant of `shell_quote`, `resolve_repo_toplevel`, `spawn_rebase`, `run_git_capture`, and `run_git_silent`. Three of the newer leaf modules (`change_type`, `notes_push`, `replay`) additionally set `LC_ALL=C/LANG=C/LANGUAGE=C` because they parse English git error messages; the per-command duplicates didn't, which would have been a flake waiting to happen on non-English systems. Consolidate into `crates/mergify-stack/src/git.rs`: - `git_cmd(repo_dir)` — base `Command` with `-C <dir>` (when supplied) and the forced C locale. - `run_git_capture(repo_dir, args)` — trimmed stdout or `CliError::Generic` with the captured stderr. - `run_git_silent(repo_dir, args)` — same error mapping, output discarded. - `resolve_repo_toplevel(repo_dir)` — `git rev-parse --show-toplevel` as a `PathBuf`. - `spawn_rebase(repo_dir, base, sequence_editor)` — interactive rebase with the optional `GIT_SEQUENCE_EDITOR` env (the `None` arm is what `stack edit`'s "fall through to the user's editor" path used). - `shell_quote(value)` — POSIX shell single-quote escape. Every consumer module updated to import from `crate::git`; the duplicated function bodies (~50-100 lines per module) deleted. Side effects: - The previously locale-unaware per-command helpers now force `LC_ALL=C` like the leaf modules already did. Downstream call sites that match git error messages by English substring (`couldn't find remote ref`, etc.) now work consistently across locales — a latent bug fix. - Call-site signature updates: `spawn_rebase` now takes `Option<&str>` for the editor (was `&str` in most consumers, `Option<&str>` in `edit`); `commands::sync`'s `run_git` / `spawn_rebase_with_editor` got renamed to the shared `run_git_silent` / `spawn_rebase`; `commands::push`'s `run_git_silent` signature changed from `&Path` to `Option<&Path>`. Addresses Copilot's review feedback on PR #1518 (stack fixup) about helper duplication across drop/edit/fixup/... — the extraction unifies all 13 consumer modules in one commit. All 241 mergify-stack tests + the rest of the workspace pass. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> Depends-On: #1554
1 parent 5fce67b commit 17d7b8a

20 files changed

Lines changed: 270 additions & 779 deletions

crates/mergify-stack/src/change_type.rs

Lines changed: 3 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,9 @@
2222
2323
use std::io::Write;
2424
use std::path::Path;
25-
use std::process::{Command, Stdio};
25+
use std::process::Stdio;
26+
27+
use crate::git::{git_cmd, run_git_capture, run_git_silent};
2628

2729
use mergify_core::CliError;
2830
use serde::{Deserialize, Serialize};
@@ -169,50 +171,6 @@ pub fn fetch_old_pr_heads(
169171
run_git_silent(repo_dir, &args)
170172
}
171173

172-
fn git_cmd(repo_dir: Option<&Path>) -> Command {
173-
let mut cmd = Command::new("git");
174-
if let Some(dir) = repo_dir {
175-
cmd.arg("-C").arg(dir);
176-
}
177-
// Force C locale: callers may match git error messages by
178-
// English substring, which breaks under translated locales.
179-
// Mirrors `utils.subprocess_env` on the Python side.
180-
cmd.env("LC_ALL", "C").env("LANG", "C").env("LANGUAGE", "C");
181-
cmd
182-
}
183-
184-
fn run_git_capture(repo_dir: Option<&Path>, args: &[&str]) -> Result<String, CliError> {
185-
let output = git_cmd(repo_dir)
186-
.args(args)
187-
.output()
188-
.map_err(|e| CliError::Generic(format!("failed to spawn `git {}`: {e}", args.join(" "))))?;
189-
if !output.status.success() {
190-
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
191-
return Err(CliError::Generic(if stderr.is_empty() {
192-
format!("`git {}` failed", args.join(" "))
193-
} else {
194-
stderr
195-
}));
196-
}
197-
String::from_utf8(output.stdout).map_err(|e| {
198-
CliError::Generic(format!("`git {}` output is not UTF-8: {e}", args.join(" ")))
199-
})
200-
}
201-
202-
fn run_git_silent(repo_dir: Option<&Path>, args: &[&str]) -> Result<(), CliError> {
203-
let status = git_cmd(repo_dir)
204-
.args(args)
205-
.status()
206-
.map_err(|e| CliError::Generic(format!("failed to spawn `git {}`: {e}", args.join(" "))))?;
207-
if !status.success() {
208-
return Err(CliError::Generic(format!(
209-
"`git {}` exited {status}",
210-
args.join(" ")
211-
)));
212-
}
213-
Ok(())
214-
}
215-
216174
#[cfg(test)]
217175
mod tests {
218176
use super::*;

crates/mergify-stack/src/commands/checkout.rs

Lines changed: 3 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -19,8 +19,9 @@
1919
//! head, `git checkout -b <local>` on it, set upstream
2020
//! tracking to the root's base.
2121
22-
use std::path::{Path, PathBuf};
23-
use std::process::Command;
22+
use std::path::Path;
23+
24+
use crate::git::run_git_silent as run_git;
2425

2526
use mergify_core::CliError;
2627
use mergify_core::HttpClient;
@@ -224,46 +225,6 @@ fn summary_from(pull: &Value) -> Result<PullSummary, CliError> {
224225
})
225226
}
226227

227-
fn run_git(repo_dir: Option<&Path>, args: &[&str]) -> Result<(), CliError> {
228-
let mut cmd = Command::new("git");
229-
if let Some(dir) = repo_dir {
230-
cmd.arg("-C").arg(dir);
231-
}
232-
cmd.args(args);
233-
let status = cmd
234-
.status()
235-
.map_err(|e| CliError::Generic(format!("failed to spawn `git {}`: {e}", args.join(" "))))?;
236-
if !status.success() {
237-
return Err(CliError::Generic(format!(
238-
"`git {}` exited {status}",
239-
args.join(" ")
240-
)));
241-
}
242-
Ok(())
243-
}
244-
245-
// Unused but kept so future commands (sync, list) can reuse the
246-
// same toplevel-resolution.
247-
#[allow(dead_code)]
248-
fn resolve_repo_toplevel(repo_dir: Option<&Path>) -> Result<PathBuf, CliError> {
249-
let mut cmd = Command::new("git");
250-
if let Some(dir) = repo_dir {
251-
cmd.arg("-C").arg(dir);
252-
}
253-
cmd.args(["rev-parse", "--show-toplevel"]);
254-
let output = cmd
255-
.output()
256-
.map_err(|e| CliError::Generic(format!("spawn git rev-parse: {e}")))?;
257-
if !output.status.success() {
258-
return Err(CliError::Generic(
259-
"git rev-parse --show-toplevel failed".into(),
260-
));
261-
}
262-
Ok(PathBuf::from(
263-
String::from_utf8_lossy(&output.stdout).trim().to_string(),
264-
))
265-
}
266-
267228
#[cfg(test)]
268229
mod tests {
269230
use super::*;

crates/mergify-stack/src/commands/drop.rs

Lines changed: 3 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -8,12 +8,12 @@
88
//! <SHA,SHA,…>`, which deletes the targeted `pick` lines from the
99
//! todo before git replays the rebase.
1010
11-
use std::path::{Path, PathBuf};
12-
use std::process::Command;
11+
use std::path::Path;
1312

1413
use mergify_core::CliError;
1514

1615
use crate::change_id;
16+
use crate::git::{resolve_repo_toplevel, run_git_capture, shell_quote, spawn_rebase};
1717
use crate::local_commits::{self, LocalCommit};
1818
use crate::trunk;
1919

@@ -91,7 +91,7 @@ pub fn run(opts: &Options<'_>) -> Result<Outcome, CliError> {
9191

9292
let shas: Vec<String> = resolved.iter().map(|c| c.sha.clone()).collect();
9393
let editor = build_sequence_editor(opts.mergify_binary, &shas);
94-
spawn_rebase(&repo_dir, &base, &editor)?;
94+
spawn_rebase(&repo_dir, &base, Some(&editor))?;
9595
Ok(Outcome::Dropped { dropped: resolved })
9696
}
9797

@@ -140,55 +140,6 @@ fn build_sequence_editor(binary: &Path, shas: &[String]) -> String {
140140
format!("{bin} _internal rebase-todo-rewrite --action drop --shas {shas}")
141141
}
142142

143-
fn shell_quote(value: &str) -> String {
144-
let escaped = value.replace('\'', "'\\''");
145-
format!("'{escaped}'")
146-
}
147-
148-
fn resolve_repo_toplevel(repo_dir: Option<&Path>) -> Result<PathBuf, CliError> {
149-
let raw = run_git_capture(repo_dir, &["rev-parse", "--show-toplevel"])?;
150-
Ok(PathBuf::from(raw))
151-
}
152-
153-
fn spawn_rebase(repo_dir: &Path, base: &str, sequence_editor: &str) -> Result<(), CliError> {
154-
let status = Command::new("git")
155-
.arg("-C")
156-
.arg(repo_dir)
157-
.args(["rebase", "-i", base])
158-
.env("GIT_SEQUENCE_EDITOR", sequence_editor)
159-
.status()
160-
.map_err(|e| CliError::Generic(format!("failed to spawn `git rebase -i`: {e}")))?;
161-
if !status.success() {
162-
return Err(CliError::Generic(format!(
163-
"`git rebase -i {base}` exited {status}"
164-
)));
165-
}
166-
Ok(())
167-
}
168-
169-
fn run_git_capture(repo_dir: Option<&Path>, args: &[&str]) -> Result<String, CliError> {
170-
let mut cmd = Command::new("git");
171-
if let Some(dir) = repo_dir {
172-
cmd.arg("-C").arg(dir);
173-
}
174-
cmd.args(args);
175-
let output = cmd
176-
.output()
177-
.map_err(|e| CliError::Generic(format!("failed to spawn `git {}`: {e}", args.join(" "))))?;
178-
if !output.status.success() {
179-
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
180-
return Err(CliError::Generic(if stderr.is_empty() {
181-
format!("`git {}` failed", args.join(" "))
182-
} else {
183-
stderr
184-
}));
185-
}
186-
let stdout = String::from_utf8(output.stdout).map_err(|e| {
187-
CliError::Generic(format!("`git {}` output is not UTF-8: {e}", args.join(" ")))
188-
})?;
189-
Ok(stdout.trim_end().to_string())
190-
}
191-
192143
#[cfg(test)]
193144
mod tests {
194145
use super::*;

crates/mergify-stack/src/commands/edit.rs

Lines changed: 2 additions & 63 deletions
Original file line numberDiff line numberDiff line change
@@ -9,12 +9,12 @@
99
//! binary's `_internal rebase-todo-rewrite` subcommand, set as
1010
//! `GIT_SEQUENCE_EDITOR` before spawning `git rebase -i <base>`.
1111
12-
use std::path::{Path, PathBuf};
13-
use std::process::Command;
12+
use std::path::Path;
1413

1514
use mergify_core::CliError;
1615

1716
use crate::change_id;
17+
use crate::git::{resolve_repo_toplevel, run_git_capture, shell_quote, spawn_rebase};
1818
use crate::local_commits;
1919
use crate::trunk;
2020

@@ -142,67 +142,6 @@ fn build_sequence_editor(binary: &Path, sha: &str) -> String {
142142
format!("{bin} _internal rebase-todo-rewrite --action edit --sha {sha}")
143143
}
144144

145-
fn shell_quote(value: &str) -> String {
146-
// Conservative single-quote escaping for sh: wrap in `'…'`,
147-
// escape embedded `'` as `'\''`. Good enough for the binary
148-
// path and a SHA — both are safe ASCII payloads in practice
149-
// but we never want a path-with-spaces to break the rebase.
150-
let escaped = value.replace('\'', "'\\''");
151-
format!("'{escaped}'")
152-
}
153-
154-
fn resolve_repo_toplevel(repo_dir: Option<&Path>) -> Result<PathBuf, CliError> {
155-
let raw = run_git_capture(repo_dir, &["rev-parse", "--show-toplevel"])?;
156-
Ok(PathBuf::from(raw))
157-
}
158-
159-
fn spawn_rebase(
160-
repo_dir: &Path,
161-
base: &str,
162-
sequence_editor: Option<&str>,
163-
) -> Result<(), CliError> {
164-
let mut cmd = Command::new("git");
165-
cmd.arg("-C").arg(repo_dir).args(["rebase", "-i", base]);
166-
if let Some(editor) = sequence_editor {
167-
cmd.env("GIT_SEQUENCE_EDITOR", editor);
168-
}
169-
let status = cmd
170-
.status()
171-
.map_err(|e| CliError::Generic(format!("failed to spawn `git rebase -i`: {e}")))?;
172-
if !status.success() {
173-
// The rebase itself printed whatever it had to print on
174-
// stderr — surface a short generic error and let the
175-
// user follow up with `git status` / `git rebase --abort`.
176-
return Err(CliError::Generic(format!(
177-
"`git rebase -i {base}` exited {status}"
178-
)));
179-
}
180-
Ok(())
181-
}
182-
183-
fn run_git_capture(repo_dir: Option<&Path>, args: &[&str]) -> Result<String, CliError> {
184-
let mut cmd = Command::new("git");
185-
if let Some(dir) = repo_dir {
186-
cmd.arg("-C").arg(dir);
187-
}
188-
cmd.args(args);
189-
let output = cmd
190-
.output()
191-
.map_err(|e| CliError::Generic(format!("failed to spawn `git {}`: {e}", args.join(" "))))?;
192-
if !output.status.success() {
193-
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
194-
return Err(CliError::Generic(if stderr.is_empty() {
195-
format!("`git {}` failed", args.join(" "))
196-
} else {
197-
stderr
198-
}));
199-
}
200-
let stdout = String::from_utf8(output.stdout).map_err(|e| {
201-
CliError::Generic(format!("`git {}` output is not UTF-8: {e}", args.join(" ")))
202-
})?;
203-
Ok(stdout.trim_end().to_string())
204-
}
205-
206145
#[cfg(test)]
207146
mod tests {
208147
use super::*;

crates/mergify-stack/src/commands/fixup.rs

Lines changed: 3 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -13,12 +13,12 @@
1313
//! collapse it into the trunk's parent. Python rejects this with
1414
//! `INVALID_STATE`; we mirror the check.
1515
16-
use std::path::{Path, PathBuf};
17-
use std::process::Command;
16+
use std::path::Path;
1817

1918
use mergify_core::CliError;
2019

2120
use crate::change_id;
21+
use crate::git::{resolve_repo_toplevel, run_git_capture, shell_quote, spawn_rebase};
2222
use crate::local_commits::{self, LocalCommit};
2323
use crate::trunk;
2424

@@ -95,7 +95,7 @@ pub fn run(opts: &Options<'_>) -> Result<Outcome, CliError> {
9595

9696
let shas: Vec<String> = resolved.iter().map(|c| c.sha.clone()).collect();
9797
let editor = build_sequence_editor(opts.mergify_binary, &shas);
98-
spawn_rebase(&repo_dir, &base, &editor)?;
98+
spawn_rebase(&repo_dir, &base, Some(&editor))?;
9999
Ok(Outcome::Squashed { fixed_up: resolved })
100100
}
101101

@@ -144,55 +144,6 @@ fn build_sequence_editor(binary: &Path, shas: &[String]) -> String {
144144
format!("{bin} _internal rebase-todo-rewrite --action fixup --shas {shas}")
145145
}
146146

147-
fn shell_quote(value: &str) -> String {
148-
let escaped = value.replace('\'', "'\\''");
149-
format!("'{escaped}'")
150-
}
151-
152-
fn resolve_repo_toplevel(repo_dir: Option<&Path>) -> Result<PathBuf, CliError> {
153-
let raw = run_git_capture(repo_dir, &["rev-parse", "--show-toplevel"])?;
154-
Ok(PathBuf::from(raw))
155-
}
156-
157-
fn spawn_rebase(repo_dir: &Path, base: &str, sequence_editor: &str) -> Result<(), CliError> {
158-
let status = Command::new("git")
159-
.arg("-C")
160-
.arg(repo_dir)
161-
.args(["rebase", "-i", base])
162-
.env("GIT_SEQUENCE_EDITOR", sequence_editor)
163-
.status()
164-
.map_err(|e| CliError::Generic(format!("failed to spawn `git rebase -i`: {e}")))?;
165-
if !status.success() {
166-
return Err(CliError::Generic(format!(
167-
"`git rebase -i {base}` exited {status}"
168-
)));
169-
}
170-
Ok(())
171-
}
172-
173-
fn run_git_capture(repo_dir: Option<&Path>, args: &[&str]) -> Result<String, CliError> {
174-
let mut cmd = Command::new("git");
175-
if let Some(dir) = repo_dir {
176-
cmd.arg("-C").arg(dir);
177-
}
178-
cmd.args(args);
179-
let output = cmd
180-
.output()
181-
.map_err(|e| CliError::Generic(format!("failed to spawn `git {}`: {e}", args.join(" "))))?;
182-
if !output.status.success() {
183-
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
184-
return Err(CliError::Generic(if stderr.is_empty() {
185-
format!("`git {}` failed", args.join(" "))
186-
} else {
187-
stderr
188-
}));
189-
}
190-
let stdout = String::from_utf8(output.stdout).map_err(|e| {
191-
CliError::Generic(format!("`git {}` output is not UTF-8: {e}", args.join(" ")))
192-
})?;
193-
Ok(stdout.trim_end().to_string())
194-
}
195-
196147
#[cfg(test)]
197148
mod tests {
198149
use super::*;

0 commit comments

Comments
 (0)