Skip to content

Commit 6f80c90

Browse files
authored
feat(submit): handle uncommitted changes before push (#140)
1 parent 28f5a1a commit 6f80c90

File tree

10 files changed

+484
-13
lines changed

10 files changed

+484
-13
lines changed

README.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -200,13 +200,17 @@ rung submit --dry-run # Preview what would happen without updatin
200200
rung submit --draft # Create PRs as drafts
201201
rung submit --force # Force push (uses --force-with-lease)
202202
rung submit --title "My PR title" # Custom title (overrides commit message)
203+
rung submit --amend # Amend uncommitted changes to current commit
204+
rung submit -m "commit message" # Create new commit with uncommitted changes
203205
```
204206

205207
**Options:**
206208

207209
- `--draft` - Create PRs as drafts
208210
- `--force` - Force push using `--force-with-lease` for safety, even if remote has changes
209211
- `-t, --title <title>` - Custom PR title for current branch (overrides commit message)
212+
- `--amend` - Amend staged/unstaged changes to the current commit before pushing *(v0.8.0+)*
213+
- `-m, --message <message>` - Create a new commit with the given message before pushing *(v0.8.0+)*
210214

211215
### `rung merge`
212216

crates/rung-cli/src/commands/mod.rs

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,13 @@ pub enum Commands {
142142
///
143143
/// Pushes all stack branches to the remote and creates or
144144
/// updates pull requests with stack navigation links.
145+
///
146+
/// If there are uncommitted changes, you'll be prompted to choose:
147+
/// - Amend to last commit
148+
/// - Create a new commit
149+
/// - Skip (proceed without committing)
150+
///
151+
/// Use --amend or -m "message" to skip the prompt.
145152
#[command(alias = "sm")]
146153
Submit {
147154
/// Create PRs as drafts (won't trigger CI).
@@ -159,6 +166,16 @@ pub enum Commands {
159166
/// Custom PR title for current branch (overrides auto-generated title).
160167
#[arg(long, short)]
161168
title: Option<String>,
169+
170+
/// Amend staged changes to the last commit before pushing.
171+
/// Stages all changes first if working directory is dirty.
172+
#[arg(long, conflicts_with = "message")]
173+
amend: bool,
174+
175+
/// Create a new commit with this message before pushing.
176+
/// Stages all changes first if working directory is dirty.
177+
#[arg(long, short, conflicts_with = "amend")]
178+
message: Option<String>,
162179
},
163180

164181
/// Undo the last sync operation. [alias: un]

crates/rung-cli/src/commands/submit.rs

Lines changed: 289 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
//! `rung submit` command - Push branches and create/update PRs.
22
33
use anyhow::{Context, Result, bail};
4+
use inquire::{Select, Text};
45
use rung_core::{State, stack::Stack, sync};
56
use rung_git::{RemoteDivergence, Repository};
67
use rung_github::{Auth, GitHubClient};
@@ -73,8 +74,10 @@ pub fn run(
7374
draft: bool,
7475
force: bool,
7576
custom_title: Option<&str>,
77+
amend: bool,
78+
message: Option<&str>,
7679
) -> Result<()> {
77-
let (repo, state, mut stack) = setup_submit()?;
80+
let (repo, state, mut stack) = setup_submit(json, amend, message)?;
7881

7982
if stack.is_empty() {
8083
if json {
@@ -92,9 +95,6 @@ pub fn run(
9295
return Ok(());
9396
}
9497

95-
// Ensure on branch
96-
utils::ensure_on_branch(&repo)?;
97-
9898
let config = SubmitConfig {
9999
draft,
100100
custom_title,
@@ -193,7 +193,13 @@ fn output_json(output: &SubmitOutput) -> Result<()> {
193193
}
194194

195195
/// Set up repository, state, and stack for submit.
196-
fn setup_submit() -> Result<(Repository, State, rung_core::stack::Stack)> {
196+
///
197+
/// Handles uncommitted changes based on flags or interactive prompt.
198+
fn setup_submit(
199+
json: bool,
200+
amend: bool,
201+
message: Option<&str>,
202+
) -> Result<(Repository, State, rung_core::stack::Stack)> {
197203
let repo = Repository::open_current().context("Not inside a git repository")?;
198204
let workdir = repo.workdir().context("Cannot run in bare repository")?;
199205
let state = State::new(workdir)?;
@@ -202,18 +208,147 @@ fn setup_submit() -> Result<(Repository, State, rung_core::stack::Stack)> {
202208
bail!("Rung not initialized - run `rung init` first");
203209
}
204210

205-
repo.require_clean()?;
211+
// Validate branch context BEFORE any history-changing operations
212+
utils::ensure_on_branch(&repo)?;
213+
214+
// Handle uncommitted changes (may amend/commit)
215+
handle_uncommitted_changes(&repo, json, amend, message)?;
216+
206217
let stack = state.load_stack()?;
207218

208219
Ok((repo, state, stack))
209220
}
210221

222+
/// Handle uncommitted changes before submit.
223+
///
224+
/// Stages and commits/amends based on flags or interactive prompt.
225+
fn handle_uncommitted_changes(
226+
repo: &Repository,
227+
json: bool,
228+
amend: bool,
229+
message: Option<&str>,
230+
) -> Result<()> {
231+
// Guard: amend and message are mutually exclusive
232+
if amend && message.is_some() {
233+
bail!("Cannot use both --amend and --message flags together");
234+
}
235+
236+
// If working directory is clean, nothing to do
237+
if repo.is_clean()? {
238+
return Ok(());
239+
}
240+
241+
// Handle based on flags
242+
if amend {
243+
if !json {
244+
output::info("Staging changes and amending last commit...");
245+
}
246+
repo.stage_all()?;
247+
repo.amend_commit(None)?;
248+
if !json {
249+
output::success(" Amended last commit");
250+
}
251+
return Ok(());
252+
}
253+
254+
if let Some(msg) = message {
255+
let msg = msg.trim();
256+
if msg.is_empty() {
257+
bail!("Commit message cannot be empty");
258+
}
259+
if !json {
260+
output::info("Staging changes and creating commit...");
261+
}
262+
repo.stage_all()?;
263+
// Use git CLI for commit to ensure consistency with stage_all
264+
create_commit_cli(repo, msg)?;
265+
if !json {
266+
output::success(&format!(" Created commit: {msg}"));
267+
}
268+
return Ok(());
269+
}
270+
271+
// JSON mode can't prompt - error out
272+
if json {
273+
bail!("Uncommitted changes found. Use --amend or -m \"message\" to handle them.");
274+
}
275+
276+
// Interactive prompt
277+
prompt_and_handle_uncommitted(repo)
278+
}
279+
280+
/// Prompt user for how to handle uncommitted changes.
281+
fn prompt_and_handle_uncommitted(repo: &Repository) -> Result<()> {
282+
output::warn("Uncommitted changes found.");
283+
284+
let options = vec![
285+
"Amend to last commit (recommended)",
286+
"Create new commit",
287+
"Skip (keep changes uncommitted)",
288+
];
289+
290+
let selection = Select::new("How do you want to handle uncommitted changes?", options)
291+
.prompt()
292+
.context("Prompt cancelled")?;
293+
294+
match selection {
295+
"Amend to last commit (recommended)" => {
296+
output::info("Staging changes and amending last commit...");
297+
repo.stage_all()?;
298+
repo.amend_commit(None)?;
299+
output::success(" Amended last commit");
300+
}
301+
"Create new commit" => {
302+
let commit_message = Text::new("Commit message:")
303+
.prompt()
304+
.context("Prompt cancelled")?;
305+
306+
if commit_message.trim().is_empty() {
307+
bail!("Commit message cannot be empty");
308+
}
309+
310+
output::info("Staging changes and creating commit...");
311+
repo.stage_all()?;
312+
// Use git CLI for commit to ensure consistency with stage_all
313+
create_commit_cli(repo, &commit_message)?;
314+
output::success(&format!(" Created commit: {commit_message}"));
315+
}
316+
"Skip (keep changes uncommitted)" => {
317+
output::info("Skipping commit - proceeding with push only");
318+
output::detail(" Note: Uncommitted changes will not be included in the push");
319+
}
320+
_ => bail!("Invalid selection"),
321+
}
322+
323+
Ok(())
324+
}
325+
211326
/// Get owner and repo name from remote.
212327
fn get_remote_info(repo: &Repository) -> Result<(String, String)> {
213328
let origin_url = repo.origin_url().context("No origin remote configured")?;
214329
Repository::parse_github_remote(&origin_url).context("Could not parse GitHub remote URL")
215330
}
216331

332+
/// Create a commit using git CLI.
333+
///
334+
/// Uses `git commit -m` for consistency with `stage_all` which also uses CLI.
335+
fn create_commit_cli(repo: &Repository, message: &str) -> Result<()> {
336+
let workdir = repo.workdir().context("Cannot run in bare repository")?;
337+
338+
let output = std::process::Command::new("git")
339+
.args(["commit", "-m", message])
340+
.current_dir(workdir)
341+
.output()
342+
.context("Failed to execute git commit")?;
343+
344+
if output.status.success() {
345+
Ok(())
346+
} else {
347+
let stderr = String::from_utf8_lossy(&output.stderr);
348+
bail!("git commit failed: {stderr}");
349+
}
350+
}
351+
217352
/// Warn if a branch has diverged from its remote and force is not enabled.
218353
fn warn_if_diverged(repo: &Repository, branch: &str, force: bool, json: bool) {
219354
if force || json {
@@ -579,4 +714,152 @@ mod test {
579714
let result = validate_sync_state(&repo, &stack, "nonexistent-branch", false);
580715
assert!(result.is_ok(), "Should handle fetch errors gracefully");
581716
}
717+
718+
#[test]
719+
fn test_handle_uncommitted_changes_clean_repo() {
720+
let (_temp, repo) = setup_test_repo();
721+
722+
// Clean repo should return Ok immediately
723+
let result = handle_uncommitted_changes(&repo, false, false, None);
724+
assert!(result.is_ok(), "Clean repo should succeed without action");
725+
}
726+
727+
#[test]
728+
fn test_handle_uncommitted_changes_amend_flag() {
729+
let (temp, repo) = setup_test_repo();
730+
731+
// Create a branch with a commit
732+
create_branch_with_commits(&temp, "feature-amend", "Initial feature");
733+
734+
// Get original commit
735+
let original_commit = repo.branch_commit("feature-amend").unwrap();
736+
737+
// Modify the tracked file (not create a new untracked file)
738+
let file = temp.path().join("feature.txt");
739+
fs::write(&file, "Modified content").expect("Failed to write file");
740+
assert!(!repo.is_clean().unwrap(), "Repo should be dirty");
741+
742+
// Handle with amend flag
743+
let result = handle_uncommitted_changes(&repo, false, true, None);
744+
assert!(result.is_ok(), "Amend should succeed");
745+
746+
// Verify commit was amended (different OID)
747+
let new_commit = repo.branch_commit("feature-amend").unwrap();
748+
assert_ne!(original_commit, new_commit, "Commit should be amended");
749+
750+
// Verify working directory is clean
751+
assert!(repo.is_clean().unwrap(), "Repo should be clean after amend");
752+
}
753+
754+
#[test]
755+
fn test_handle_uncommitted_changes_message_flag() {
756+
let (temp, repo) = setup_test_repo();
757+
758+
// Create a branch with a commit
759+
create_branch_with_commits(&temp, "feature-msg", "Initial feature");
760+
761+
// Get original commit
762+
let original_commit = repo.branch_commit("feature-msg").unwrap();
763+
764+
// Modify the tracked file (not create a new untracked file)
765+
let file = temp.path().join("feature.txt");
766+
fs::write(&file, "Modified content").expect("Failed to write file");
767+
assert!(!repo.is_clean().unwrap(), "Repo should be dirty");
768+
769+
// Handle with message flag
770+
let result = handle_uncommitted_changes(&repo, false, false, Some("New commit"));
771+
assert!(result.is_ok(), "New commit should succeed");
772+
773+
// Verify a new commit was created (different OID, new message)
774+
let new_commit = repo.branch_commit("feature-msg").unwrap();
775+
assert_ne!(original_commit, new_commit, "New commit should be created");
776+
777+
// Verify commit message
778+
let message = repo.branch_commit_message("feature-msg").unwrap();
779+
assert!(
780+
message.starts_with("New commit"),
781+
"Commit message should match"
782+
);
783+
784+
// Verify working directory is clean
785+
assert!(
786+
repo.is_clean().unwrap(),
787+
"Repo should be clean after commit"
788+
);
789+
}
790+
791+
#[test]
792+
fn test_handle_uncommitted_changes_json_mode_errors() {
793+
let (temp, repo) = setup_test_repo();
794+
795+
// Modify the README (a tracked file) to create uncommitted changes
796+
let file = temp.path().join("README.md");
797+
fs::write(&file, "Modified README content").expect("Failed to write file");
798+
assert!(!repo.is_clean().unwrap(), "Repo should be dirty");
799+
800+
// JSON mode without flags should error
801+
let result = handle_uncommitted_changes(&repo, true, false, None);
802+
assert!(result.is_err(), "JSON mode without flags should error");
803+
804+
let error_msg = result.unwrap_err().to_string();
805+
assert!(
806+
error_msg.contains("Uncommitted changes found"),
807+
"Error should mention uncommitted changes"
808+
);
809+
}
810+
811+
#[test]
812+
fn test_handle_uncommitted_changes_empty_message_errors() {
813+
let (temp, repo) = setup_test_repo();
814+
815+
// Modify a tracked file to create uncommitted changes
816+
let file = temp.path().join("README.md");
817+
fs::write(&file, "Modified README content").expect("Failed to write file");
818+
assert!(!repo.is_clean().unwrap(), "Repo should be dirty");
819+
820+
// Empty message should error
821+
let result = handle_uncommitted_changes(&repo, false, false, Some(""));
822+
assert!(result.is_err(), "Empty message should error");
823+
assert!(result.unwrap_err().to_string().contains("cannot be empty"));
824+
825+
// Whitespace-only message should also error
826+
let result = handle_uncommitted_changes(&repo, false, false, Some(" "));
827+
assert!(result.is_err(), "Whitespace-only message should error");
828+
assert!(result.unwrap_err().to_string().contains("cannot be empty"));
829+
}
830+
831+
#[test]
832+
fn test_handle_uncommitted_changes_conflicting_flags_errors() {
833+
let (_temp, repo) = setup_test_repo();
834+
835+
// Both amend and message should error (invariant guard)
836+
let result = handle_uncommitted_changes(&repo, false, true, Some("message"));
837+
assert!(result.is_err(), "Conflicting flags should error");
838+
assert!(
839+
result
840+
.unwrap_err()
841+
.to_string()
842+
.contains("Cannot use both --amend and --message")
843+
);
844+
}
845+
846+
#[test]
847+
fn test_handle_uncommitted_changes_json_mode_with_amend() {
848+
let (temp, repo) = setup_test_repo();
849+
850+
// Create a branch with a commit
851+
create_branch_with_commits(&temp, "feature-json", "Initial feature");
852+
853+
// Modify the tracked file (not create a new untracked file)
854+
let file = temp.path().join("feature.txt");
855+
fs::write(&file, "Modified JSON content").expect("Failed to write file");
856+
assert!(!repo.is_clean().unwrap(), "Repo should be dirty");
857+
858+
// JSON mode with amend flag should succeed
859+
let result = handle_uncommitted_changes(&repo, true, true, None);
860+
assert!(result.is_ok(), "JSON mode with amend should succeed");
861+
862+
// Verify working directory is clean
863+
assert!(repo.is_clean().unwrap(), "Repo should be clean after amend");
864+
}
582865
}

0 commit comments

Comments
 (0)