From dea7f10c35e534619a6c6637ee36e4da991a5dec Mon Sep 17 00:00:00 2001 From: Nishad <133812901+nishu-builder@users.noreply.github.com> Date: Sun, 20 Sep 2026 00:14:15 +0000 Subject: [PATCH] Use import and checkout for local editing --- rust/crates/caos-cli/TUI.md | 30 ++-- rust/crates/caos-cli/src/bin/tui/app.rs | 189 +++------------------- rust/crates/caos-cli/src/bin/tui/setup.rs | 7 +- rust/crates/caos-cli/src/host_git.rs | 161 ------------------ 4 files changed, 32 insertions(+), 355 deletions(-) diff --git a/rust/crates/caos-cli/TUI.md b/rust/crates/caos-cli/TUI.md index 9abb85643..8a794e4a0 100644 --- a/rust/crates/caos-cli/TUI.md +++ b/rust/crates/caos-cli/TUI.md @@ -157,8 +157,6 @@ so it never leaves the conversation pane. | `Ctrl+Y` | Release mouse capture and freeze redraws for native selection | | `/checkout [directory]` | Check out this commit, reusing its local directory when omitted | | `Ctrl+O` | Browse conversation files and source-tree diffs | -| `/pr [remote-URL]` | Preview one PR; Enter confirms | -| `/publish-branch [remote-URL]` | Preview and push this snapshot without creating a PR | | `Ctrl+R` | Reload completed conversation history | | `Ctrl+C` | Clear a non-empty prompt; exit when the prompt is empty | @@ -173,10 +171,7 @@ Completed user and agent turns show branchable hashes in the transcript. Enter TUI. Enter `/title ` to change the shared title without changing the conversation ID (the metadata update advances its conversation head). Enter `/model ` to select the client-wide model for later turns; known model names type ahead. `/model default` restores the -client default. Enter `/update-tree ` to send an ordinary -user turn whose commit also folds in edits in that gitlink's remembered checkout — the -intended companion to `/checkout [directory]` (check out the head, edit files, then -`/update-tree ` with the text you want in that turn). Activity entries +client default. Activity entries show the durable hashes of internal harness steps for inspection; those step trees contain harness metadata and are not branch points. @@ -318,20 +313,15 @@ conversation history. Opening and running conversations never overwrite a checkout. `/checkout [directory]` uses an explicit destination or reuses that gitlink's remembered local directory. The destination must be a clean Git checkout or an empty/new directory. The client imports the code objects and detaches HEAD -at the named commit. `/update-tree ` commits local edits in -that gitlink's remembered checkout and imports their -closure into the client before submission. These commands never replace the -internal harness. - -Publication preserves source tree history, uses leased branch updates, and -checks conflict cleanup before preview and again before pushing. Resolve a -nonempty `.caos/conflicts` ledger by fixing each path and clearing its entries. -Saving an edited source tree removes an empty ledger and prunes its empty -`.caos` directory. Any remaining `.caos` entry blocks publication; publishing -never rewrites the selected commit. It leaves the local -checkout and index unchanged. Credentials remain in the local secret store; -the launcher reuses an existing checkout store or its own persistent store under -the data directory. +at the named commit. Commit local edits with Git, then use +`/import imports/local-edit /absolute/path/to/checkout` to attach that commit at +an unused conversation path. Ask the agent to integrate the imported source. +These commands never replace the internal harness. + +The agent publishes branches and manages PRs. See +[GitHub interactions](../../../design/agent-github.md) for the workflow. +Credentials are supplied through the local secret store; the launcher uses the +checkout's store or its persistent store under the data directory. Over SSH, clipboard copying uses a terminal escape sequence. “Copy requested” diff --git a/rust/crates/caos-cli/src/bin/tui/app.rs b/rust/crates/caos-cli/src/bin/tui/app.rs index f902491f9..b4dcb1fa3 100644 --- a/rust/crates/caos-cli/src/bin/tui/app.rs +++ b/rust/crates/caos-cli/src/bin/tui/app.rs @@ -23,7 +23,7 @@ use ratatui_crossterm::crossterm::event::{ }; use super::args::Args; -use super::source_tree::{commit_working_tree, load_conversation_source_tree}; +use super::source_tree::load_conversation_source_tree; use super::CopyOutcome; #[path = "filesystem.rs"] @@ -850,7 +850,6 @@ enum AppAction { Commands, Reference, Title, - UpdateTree, Import, BrowseFiles, NewConversation, @@ -862,12 +861,6 @@ enum AppAction { SelectionLock, } -impl AppAction { - fn submits_message(self) -> bool { - matches!(self, Self::UpdateTree) - } -} - #[derive(Clone, Copy, Debug, PartialEq, Eq)] struct Command { name: &'static str, @@ -891,7 +884,7 @@ const MODEL_OPTIONS: [&str; 8] = [ "claude-opus-4-6", ]; -const COMMANDS: [Command; 11] = [ +const COMMANDS: [Command; 10] = [ Command { name: "/from", usage: "/from ", @@ -913,13 +906,6 @@ const COMMANDS: [Command; 11] = [ action: AppAction::Title, takes_argument: true, }, - Command { - name: "/update-tree", - usage: "/update-tree ", - description: "submit local edits", - action: AppAction::UpdateTree, - takes_argument: true, - }, Command { name: "/checkout", usage: "/checkout [directory]", @@ -2177,17 +2163,15 @@ impl App { return; } if let Some((command, arguments)) = parse_command(raw) { - if !command.action.submits_message() { - if command.takes_argument == arguments.is_empty() { - self.selected_mut() - .show_command_error(format!("usage: {}", command.usage)); - } else { - self.selected_mut().input_history = None; - self.selected_mut().composer.take_message(); - self.run_local_command(command, arguments); - } - return; + if command.takes_argument == arguments.is_empty() { + self.selected_mut() + .show_command_error(format!("usage: {}", command.usage)); + } else { + self.selected_mut().input_history = None; + self.selected_mut().composer.take_message(); + self.run_local_command(command, arguments); } + return; } if self.selected().forking { self.selected_mut() @@ -2204,80 +2188,10 @@ impl App { }; let state = self.selected_mut(); state.reference_notice = None; - // Local commands were handled above; only message-submitting commands - // and ordinary text reach the request path. - let mut human_tree = None; - let mut proposal_base = None; - let mut source_path = None; - let message = if let Some((command, arguments)) = parse_command(&raw) { - debug_assert!(command.action.submits_message()); - let Some((name, message)) = parse_update_tree(arguments) else { - self.selected_mut() - .show_command_error(format!("usage: {}", command.usage)); - self.selected_mut().composer.restore_message(&raw); - return; - }; - let source_tree = match self - .selected() - .source_trees - .iter() - .find(|source| source.name == name) - { - Some(source) => source.head.clone(), - None => { - self.selected_mut() - .show_command_error(format!("no source-tree gitlink at {name:?}")); - self.selected_mut().composer.restore_message(&raw); - return; - } - }; - let committed = super::launcher::checkout_for( - &self.repo_dir, - &self.selected().id, - &name, - &source_tree, - ) - .and_then(|checkout| { - let (commit, base) = commit_working_tree(message, &source_tree, &checkout)?; - super::launcher::import_local_commit( - &checkout, - &self.repo_dir, - &conversation_protocol::v3::Oid::parse(&commit, "local edit")?, - )?; - Ok((commit, base)) - }); - match committed { - Ok((tree, base)) => { - human_tree = Some(tree); - proposal_base = Some(base); - } - Err(error) => { - self.selected_mut().show_command_error(error); - return; - } - } - source_path = Some(name); - message.to_string() - } else { - raw - }; - self.send_message( - self.selected, - message, - human_tree, - proposal_base, - source_path, - ); + self.send_message(self.selected, raw); } - fn send_message( - &mut self, - index: usize, - message: String, - human_tree: Option, - proposal_base: Option, - source_path: Option, - ) { + fn send_message(&mut self, index: usize, message: String) { let interjecting = self.conversations[index].running; let should_generate_title = !interjecting && self.conversations[index].automatic_title @@ -2315,7 +2229,7 @@ impl App { let tx = self.tx.clone(); let mut options = self.conversations[index].turn_options.clone(); - options.source_tree = source_path; + options.source_tree = None; let conversation = self.conversations[index].id.clone(); let repo_dir = self.repo_dir.clone(); if should_generate_title { @@ -2347,8 +2261,8 @@ impl App { &options, &conversation, &message, - human_tree.as_deref(), - proposal_base.as_deref(), + None, + None, )?; let _ = committed_tx.send(UiMessage::SubmissionCommitted { conversation: conversation.clone(), @@ -2389,8 +2303,8 @@ impl App { &options, &conversation, &message, - human_tree.as_deref(), - proposal_base.as_deref(), + None, + None, |commit| { let _ = event_tx.send(UiMessage::SubmissionCommitted { conversation: conversation.clone(), @@ -2439,7 +2353,6 @@ impl App { } fn run_local_command(&mut self, command: &Command, arguments: &str) { - debug_assert!(!command.action.submits_message()); match command.action { AppAction::Help | AppAction::Commands | AppAction::Archive => { self.execute_action(command.action) @@ -2468,7 +2381,6 @@ impl App { } AppAction::From => self.start_from_hash(arguments), AppAction::Title => self.rename_selected(arguments), - AppAction::UpdateTree => unreachable!("message command reached local dispatch"), AppAction::NewConversation | AppAction::Activity | AppAction::Tools @@ -3540,7 +3452,6 @@ impl App { | AppAction::Model | AppAction::Reference | AppAction::Title - | AppAction::UpdateTree | AppAction::Import => unreachable!("slash action needs arguments"), } } @@ -3908,21 +3819,6 @@ impl App { } } -// Parse only the path as a shell word; the message is ordinary prose. -fn parse_update_tree(arguments: &str) -> Option<(String, &str)> { - arguments - .char_indices() - .filter(|(_, ch)| ch.is_whitespace()) - .find_map(|(end, _)| { - let words = shell_words::split(&arguments[..end]).ok()?; - let message = arguments[end..].trim(); - match words.as_slice() { - [path] if !path.is_empty() && !message.is_empty() => Some((path.clone(), message)), - _ => None, - } - }) -} - fn screen_point(column: u16, row: u16, area: Rect) -> TranscriptPoint { TranscriptPoint { row: row.clamp(area.y, area.bottom().saturating_sub(1)), @@ -4390,36 +4286,6 @@ mod tests { assert!(!header.contains("head fffffff")); } - #[test] - fn update_tree_requires_an_explicit_source_tree() { - let mut conversation = state("talk-1"); - conversation.source_trees = vec![ - source_tree_diff("main", 'a', 'b', ""), - source_tree_diff("side", 'c', 'd', ""), - ]; - conversation - .composer - .insert_str("/update-tree include edits"); - let (mut app, _) = app_with(vec![conversation]); - - app.handle_key(KeyEvent::new(KeyCode::Char('s'), KeyModifiers::CONTROL)); - - assert_eq!( - app.selected().command_error.as_deref(), - Some("no source-tree gitlink at \"include\"") - ); - assert!(!app.selected().running); - assert_eq!( - parse_update_tree("feature/01-change don't drop \"quotes\""), - Some(("feature/01-change".into(), "don't drop \"quotes\"")) - ); - assert_eq!( - parse_update_tree("\"feature with spaces/01-change\" edit this"), - Some(("feature with spaces/01-change".into(), "edit this")) - ); - assert_eq!(parse_update_tree("feature/01-change"), None); - } - fn wait_for_fork(app: &mut App, id: &str) -> bool { let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5); loop { @@ -5013,7 +4879,6 @@ mod tests { "/from", "/help", "/title", - "/update-tree", "/checkout", "/import", "/commands", @@ -5052,15 +4917,6 @@ mod tests { #[test] fn command_parser_only_claims_catalog_commands() { - assert_eq!( - COMMANDS - .iter() - .filter(|command| command.action.submits_message()) - .map(|command| command.name) - .collect::>(), - ["/update-tree"] - ); - let (command, arguments) = parse_command("/title A useful title").unwrap(); assert_eq!(command.action, AppAction::Title); assert_eq!(arguments, "A useful title"); @@ -5091,10 +4947,7 @@ mod tests { assert_eq!(command.action, AppAction::Model); assert_eq!(arguments, "claude-sonnet-5"); - let (command, arguments) = - parse_command("/update-tree feature/01-change include this text").unwrap(); - assert_eq!(command.action, AppAction::UpdateTree); - assert_eq!(arguments, "feature/01-change include this text"); + assert!(parse_command("/update-tree feature/01-change include this text").is_none()); let (command, arguments) = parse_command("/help").unwrap(); assert_eq!(command.action, AppAction::Help); @@ -5318,9 +5171,7 @@ mod tests { .join("\n"); assert!(rendered.contains("> /from — start a conversation from a completed turn")); assert!(rendered.contains("/title — rename the selected conversation")); - assert!( - rendered.contains("/update-tree — submit local edits") - ); + assert!(rendered.contains("/checkout [directory]")); app.selected_mut().composer = Composer::default(); app.selected_mut().composer.insert_str("/model sonnet-5"); @@ -6740,7 +6591,7 @@ mod tests { app.conversations[0].automatic_title = false; app.conversations[0].composer.insert_str("first draft"); app.conversations[1].composer.insert_str("second draft"); - app.send_message(0, "Integrate the imported PR base".into(), None, None, None); + app.send_message(0, "Integrate the imported PR base".into()); assert_eq!(app.selected().id, "talk-2"); assert_eq!(app.conversations[0].composer.text, "first draft"); assert_eq!(app.conversations[1].composer.text, "second draft"); diff --git a/rust/crates/caos-cli/src/bin/tui/setup.rs b/rust/crates/caos-cli/src/bin/tui/setup.rs index 68ebc0772..abfc98c9f 100644 --- a/rust/crates/caos-cli/src/bin/tui/setup.rs +++ b/rust/crates/caos-cli/src/bin/tui/setup.rs @@ -221,11 +221,8 @@ fn install_model_secret(root: &Path, key: &str, images: &[&str]) -> Result Result, String> { let status = Command::new("git") .args(["check-ignore", "-q", "--", SECRETS_DIR]) diff --git a/rust/crates/caos-cli/src/host_git.rs b/rust/crates/caos-cli/src/host_git.rs index edc61b6ce..493f95a5b 100644 --- a/rust/crates/caos-cli/src/host_git.rs +++ b/rust/crates/caos-cli/src/host_git.rs @@ -26,49 +26,6 @@ pub fn load_conversation_source_tree(head: &str, cwd: &Path) -> Result<(), Strin Ok(()) } -/// Commit the current working tree onto the local `HEAD` and return the new -/// commit together with its shared ancestor with the selected source_tree. -/// -/// This is the inverse of `load_conversation_source_tree`: after checking out a -/// conversation head and editing files, `/update-tree` folds those files into a -/// user-authored turn. It deliberately DOES commit — staging everything with -/// `git add -A` and committing when the tree is dirty — so the checkout is left -/// clean and its `HEAD` matches exactly what the turn receives. A later -/// `/checkout` onto the conversation's new head then succeeds instead of tripping -/// the clean-tree guard. When the working tree is already clean (the user -/// committed the changes themselves), nothing is committed and the current -/// `HEAD` is returned. `git add -A` respects `.gitignore`, so the commit -/// mirrors what a normal commit of the working tree would contain. -pub fn commit_working_tree( - message: &str, - source_tree: &str, - cwd: &Path, -) -> Result<(String, String), String> { - // HEAD can already contain user commits. Their delta starts at the shared - // ancestor, not at HEAD just before staging the remaining edits. - let base = capture_required("git", &["merge-base", "--all", source_tree, "HEAD"], cwd) - .map_err(|error| { - format!("cannot find a shared base for the checkout and source tree: {error}") - })?; - if base.lines().count() != 1 { - return Err( - "checkout and source tree have multiple merge bases; merge them before /update-tree" - .to_string(), - ); - } - capture_required("git", &["add", "-A"], cwd)?; - // `git diff --cached --quiet` exits non-zero exactly when the index differs - // from HEAD, i.e. there is something to commit. - let clean = command_output("git", &["diff", "--cached", "--quiet"], cwd)? - .status - .success(); - if !clean { - capture_required("git", &["commit", "--quiet", "-m", message], cwd)?; - } - let proposal = capture_required("git", &["rev-parse", "HEAD^{commit}"], cwd)?; - Ok((proposal, base)) -} - /// Resolve the default branch and its tip from the LOCAL branch, without /// touching the network. /// @@ -178,122 +135,4 @@ mod tests { std::fs::remove_dir_all(dir).unwrap(); } - - #[test] - fn update_tree_includes_committed_edits_and_uses_the_shared_source_tree_base() { - let dir = temp_repo("committed-update"); - let base = commit_file(&dir, "base\n", "base"); - let local = commit_file(&dir, "committed edit\n", "local"); - capture_required("git", &["checkout", "--detach", &base], &dir).unwrap(); - std::fs::write(dir.join("remote.txt"), "remote edit\n").unwrap(); - capture_required("git", &["add", "remote.txt"], &dir).unwrap(); - capture_required("git", &["commit", "-qm", "remote"], &dir).unwrap(); - let remote = capture_required("git", &["rev-parse", "HEAD"], &dir).unwrap(); - capture_required("git", &["checkout", "--detach", &local], &dir).unwrap(); - - assert_eq!( - commit_working_tree("use committed edit", &remote, &dir).unwrap(), - (local.clone(), base.clone()) - ); - std::fs::write(dir.join("new.txt"), "uncommitted edit\n").unwrap(); - let (proposal, proposal_base) = commit_working_tree("include both", &remote, &dir).unwrap(); - assert_eq!(proposal_base, base); - assert_eq!( - capture_required("git", &["rev-parse", &format!("{proposal}^")], &dir).unwrap(), - local - ); - assert_eq!( - capture_required("git", &["show", &format!("{proposal}:file.txt")], &dir).unwrap(), - "committed edit" - ); - assert_eq!( - capture_required("git", &["show", &format!("{proposal}:new.txt")], &dir).unwrap(), - "uncommitted edit" - ); - std::fs::remove_dir_all(dir).unwrap(); - } - - #[test] - fn unrelated_source_tree_is_rejected_before_staging_local_edits() { - let dir = temp_repo("unrelated-update"); - let head = commit_file(&dir, "base\n", "base"); - let tree = capture_required("git", &["rev-parse", "HEAD^{tree}"], &dir).unwrap(); - let unrelated = - capture_required("git", &["commit-tree", &tree, "-m", "unrelated"], &dir).unwrap(); - std::fs::write(dir.join("file.txt"), "dirty\n").unwrap(); - let error = commit_working_tree("update", &unrelated, &dir).unwrap_err(); - assert!(error.contains("shared base"), "{error}"); - assert_eq!( - capture_required("git", &["rev-parse", "HEAD"], &dir).unwrap(), - head - ); - capture_required("git", &["diff", "--cached", "--quiet"], &dir).unwrap(); - assert_eq!( - std::fs::read_to_string(dir.join("file.txt")).unwrap(), - "dirty\n" - ); - std::fs::remove_dir_all(dir).unwrap(); - } - - #[test] - fn update_tree_commits_the_working_tree_and_returns_its_commit() { - let dir = temp_repo("snapshot-test"); - let _base = commit_file(&dir, "base\n", "base"); - let head = commit_file(&dir, "head\n", "turn"); - - // With a clean checkout nothing is committed and the head commit is - // returned unchanged. - let head_tree = - capture_required("git", &["rev-parse", &format!("{head}^{{tree}}")], &dir).unwrap(); - assert_eq!( - commit_working_tree("noop", &head, &dir).unwrap(), - (head.clone(), head.clone()) - ); - assert_eq!( - capture_required("git", &["rev-parse", "HEAD"], &dir).unwrap(), - head - ); - - // Edit a tracked file and add an untracked one, leaving both - // uncommitted. - std::fs::write(dir.join("file.txt"), "local edit\n").unwrap(); - std::fs::write(dir.join("new.txt"), "added\n").unwrap(); - - let (proposal, proposal_base) = - commit_working_tree("fold in my edits", &head, &dir).unwrap(); - assert_eq!(proposal_base, head); - let tree = - capture_required("git", &["rev-parse", &format!("{proposal}^{{tree}}")], &dir).unwrap(); - - // The returned commit carries exactly the working tree and its base. - assert_ne!(tree, head_tree); - assert_eq!( - capture_required("git", &["show", &format!("{tree}:file.txt")], &dir).unwrap(), - "local edit" - ); - assert_eq!( - capture_required("git", &["show", &format!("{tree}:new.txt")], &dir).unwrap(), - "added" - ); - - // The edits are now committed on HEAD, so the checkout is clean and a - // later checkout of a new head would succeed. - assert_eq!( - capture_required("git", &["rev-parse", "HEAD^"], &dir).unwrap(), - head - ); - assert_eq!( - capture_required("git", &["rev-parse", "HEAD^{tree}"], &dir).unwrap(), - tree - ); - assert!(capture_required("git", &["status", "--porcelain=v1"], &dir) - .unwrap() - .is_empty()); - assert_eq!( - capture_required("git", &["show", "-s", "--format=%s", "HEAD"], &dir).unwrap(), - "fold in my edits" - ); - - std::fs::remove_dir_all(dir).unwrap(); - } }