11//! `rung submit` command - Push branches and create/update PRs.
22
33use anyhow:: { Context , Result , bail} ;
4+ use inquire:: { Select , Text } ;
45use rung_core:: { State , stack:: Stack , sync} ;
56use rung_git:: { RemoteDivergence , Repository } ;
67use 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.
212327fn 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.
218353fn 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