diff --git a/rust/crates/caos-cli/TUI.md b/rust/crates/caos-cli/TUI.md index f2b788b8..268717ba 100644 --- a/rust/crates/caos-cli/TUI.md +++ b/rust/crates/caos-cli/TUI.md @@ -220,6 +220,9 @@ conversation head, the preparation turn runs without asking the agent to merge it again. For another base, only this conversation's delta is applied, so child conversations form clean PR stacks. Unresolved conflicts stop before the branch moves. The checkout and index remain untouched. +Any conversation with a completed turn can be published, including one whose +workspace diff is empty — the transcript history is the content, and its +changes may already be part of the base. CAOS points `caos/` directly at the validated conversation head, pushes it, and uses the authenticated `gh` CLI to find or open its pull diff --git a/rust/crates/caos-cli/src/bin/tui/app.rs b/rust/crates/caos-cli/src/bin/tui/app.rs index df1dce90..503c67c7 100644 --- a/rust/crates/caos-cli/src/bin/tui/app.rs +++ b/rust/crates/caos-cli/src/bin/tui/app.rs @@ -3909,14 +3909,13 @@ impl App { if self.selected().is_busy() { self.selected_mut() .show_command_error("finish this conversation's operation before publishing it"); - } else if self - .selected() - .diff - .as_ref() - .is_none_or(|diff| diff.patch.is_empty()) - { + } else if self.selected().diff.is_none() { + // An empty workspace diff is NOT a blocker: the conversation + // history itself is worth publishing (and its changes may already + // be in the base). Only a conversation with no completed turn has + // nothing to point a branch at. self.selected_mut() - .show_command_error("there are no conversation changes to publish"); + .show_command_error("this conversation has no completed turn to publish"); } else if self.confirm_action.is_none() { let default_base = match remote_default_branch(&self.repo_dir) { Ok(branch) => branch, @@ -6163,6 +6162,45 @@ mod tests { std::fs::remove_dir_all(publish_remote).unwrap(); } + #[test] + fn publish_opens_the_base_prompt_for_an_empty_workspace_diff() { + let mut conversation = state("talk-1"); + conversation.diff = Some(WorkspaceDiff { + base_commit: "a".repeat(40), + head: "b".repeat(40), + patch: String::new(), + }); + let (mut app, _) = app_with(vec![conversation]); + let (repo, remote, _) = repo_with_default_branch("publish-empty-diff", "main"); + app.repo_dir = repo.clone(); + + app.handle_key(KeyEvent::new(KeyCode::Char('p'), KeyModifiers::CONTROL)); + + assert_eq!( + app.confirm_action, + Some(ConfirmAction::Publish { + default_base: "main".to_string(), + base_input: String::new(), + }) + ); + assert!(app.selected().command_error.is_none()); + std::fs::remove_dir_all(repo).unwrap(); + std::fs::remove_dir_all(remote).unwrap(); + } + + #[test] + fn publish_requires_a_completed_turn() { + let (mut app, _) = app_with(vec![state("talk-1")]); + + app.handle_key(KeyEvent::new(KeyCode::Char('p'), KeyModifiers::CONTROL)); + + assert!(app.confirm_action.is_none()); + assert_eq!( + app.selected().command_error.as_deref(), + Some("this conversation has no completed turn to publish") + ); + } + #[test] fn idle_chat_header_keeps_only_the_title_and_head_metadata() { let mut conversation = state("A concise title");