diff --git a/crates/but-api/src/commit.rs b/crates/but-api/src/commit.rs deleted file mode 100644 index 6893ac1241..0000000000 --- a/crates/but-api/src/commit.rs +++ /dev/null @@ -1,814 +0,0 @@ -use std::collections::{BTreeMap, HashSet}; - -use bstr::{BString, ByteSlice}; -use but_api_macros::but_api; -use but_core::{DiffSpec, sync::RepoExclusive, tree::create_tree::RejectionReason}; -use but_hunk_assignment::HunkAssignmentRequest; -use but_oplog::legacy::{OperationKind, SnapshotDetails}; -use but_rebase::graph_rebase::{ - GraphExt, LookupStep as _, - mutate::{InsertSide, RelativeTo}, -}; -use tracing::instrument; - -/// Outcome after creating a commit. -pub struct CommitCreateResult { - /// If the commit was successfully created. This should only be none if all the DiffSpecs were rejected. - pub new_commit: Option, - /// Any specs that failed to be committed. - pub rejected_specs: Vec<(RejectionReason, DiffSpec)>, - /// Commits that were replaced by this operation. Maps `old_id → new_id`. - pub replaced_commits: BTreeMap, -} - -/// Outcome after moving changes between commits. -pub struct MoveChangesResult { - /// Commits that were replaced by this operation. Maps `old_id → new_id`. - pub replaced_commits: BTreeMap, -} - -/// Outcome after rewording a commit. -pub struct CommitRewordResult { - /// The ID of the newly created commit with the updated message. - pub new_commit: gix::ObjectId, - /// Commits that were replaced by this operation. Maps `old_id → new_id`. - pub replaced_commits: BTreeMap, -} - -/// Outcome of moving a commit. -pub struct CommitMoveResult { - /// Commits that were replaced by this operation. Maps `old_id → new_id`. - pub replaced_commits: BTreeMap, -} - -/// Outcome after inserting a blank commit. -pub struct CommitInsertBlankResult { - /// The ID of the newly inserted blank commit. - pub new_commit: gix::ObjectId, - /// Commits that were replaced by this operation. Maps `old_id → new_id`. - pub replaced_commits: BTreeMap, -} - -/// JSON transport types for commit APIs. -pub mod json { - use serde::Serialize; - - use crate::{commit::CommitMoveResult, json::HexHash}; - - use super::{ - CommitCreateResult, CommitInsertBlankResult, CommitRewordResult, MoveChangesResult, - }; - - /// UI type for a move changes between commits result. - #[derive(Debug, Serialize)] - #[cfg_attr(feature = "export-schema", derive(schemars::JsonSchema))] - #[serde(rename_all = "camelCase")] - pub struct UIMoveChangesResult { - /// Commits that have been mapped from one thing to another. - /// Maps `oldId → newId`. - #[cfg_attr( - feature = "export-schema", - schemars(with = "std::collections::BTreeMap") - )] - pub replaced_commits: std::collections::BTreeMap, - } - #[cfg(feature = "export-schema")] - but_schemars::register_sdk_type!(UIMoveChangesResult); - - impl From for UIMoveChangesResult { - fn from(value: MoveChangesResult) -> Self { - let MoveChangesResult { replaced_commits } = value; - - Self { - replaced_commits: replaced_commits - .into_iter() - .map(|(old, new)| (old.into(), new.into())) - .collect(), - } - } - } - - /// UI type for creating a commit in the rebase graph. - #[derive(Debug, Serialize)] - #[cfg_attr(feature = "export-schema", derive(schemars::JsonSchema))] - #[serde(rename_all = "camelCase")] - pub struct UICommitCreateResult { - /// The new commit if one was created. - #[cfg_attr(feature = "export-schema", schemars(with = "Option"))] - pub new_commit: Option, - /// Paths that contained at least one rejected hunk, matching legacy rejection reporting semantics. - #[cfg_attr(feature = "export-schema", schemars(with = "Vec<(String, String)>"))] - pub paths_to_rejected_changes: Vec<( - but_core::tree::create_tree::RejectionReason, - but_serde::BStringForFrontend, - )>, - /// Commits that have been replaced as a side-effect of the create/amend. - /// Maps `oldId → newId`. - #[cfg_attr( - feature = "export-schema", - schemars(with = "std::collections::BTreeMap") - )] - pub replaced_commits: std::collections::BTreeMap, - } - #[cfg(feature = "export-schema")] - but_schemars::register_sdk_type!(UICommitCreateResult); - - impl From for UICommitCreateResult { - fn from(value: CommitCreateResult) -> Self { - let CommitCreateResult { - new_commit, - rejected_specs, - replaced_commits, - } = value; - - Self { - new_commit: new_commit.map(Into::into), - paths_to_rejected_changes: rejected_specs - .into_iter() - .map(|(reason, diff)| (reason, diff.path.into())) - .collect(), - replaced_commits: replaced_commits - .into_iter() - .map(|(old, new)| (old.into(), new.into())) - .collect(), - } - } - } - - /// UI type for rewording a commit. - #[derive(Debug, Serialize)] - #[cfg_attr(feature = "export-schema", derive(schemars::JsonSchema))] - #[serde(rename_all = "camelCase")] - pub struct UICommitRewordResult { - /// The new commit ID after rewording. - #[cfg_attr(feature = "export-schema", schemars(with = "String"))] - pub new_commit: HexHash, - /// Commits that have been replaced as a side-effect of the reword. - /// Maps `oldId → newId`. - #[cfg_attr( - feature = "export-schema", - schemars(with = "std::collections::BTreeMap") - )] - pub replaced_commits: std::collections::BTreeMap, - } - #[cfg(feature = "export-schema")] - but_schemars::register_sdk_type!(UICommitRewordResult); - - impl From for UICommitRewordResult { - fn from(value: CommitRewordResult) -> Self { - let CommitRewordResult { - new_commit, - replaced_commits, - } = value; - - Self { - new_commit: new_commit.into(), - replaced_commits: replaced_commits - .into_iter() - .map(|(old, new)| (old.into(), new.into())) - .collect(), - } - } - } - - /// UI type for inserting a blank commit. - #[derive(Debug, Serialize)] - #[cfg_attr(feature = "export-schema", derive(schemars::JsonSchema))] - #[serde(rename_all = "camelCase")] - pub struct UICommitInsertBlankResult { - /// The new blank commit ID. - #[cfg_attr(feature = "export-schema", schemars(with = "String"))] - pub new_commit: HexHash, - /// Commits that have been replaced as a side-effect of the insertion. - /// Maps `oldId → newId`. - #[cfg_attr( - feature = "export-schema", - schemars(with = "std::collections::BTreeMap") - )] - pub replaced_commits: std::collections::BTreeMap, - } - #[cfg(feature = "export-schema")] - but_schemars::register_sdk_type!(UICommitInsertBlankResult); - - impl From for UICommitInsertBlankResult { - fn from(value: CommitInsertBlankResult) -> Self { - let CommitInsertBlankResult { - new_commit, - replaced_commits, - } = value; - - Self { - new_commit: new_commit.into(), - replaced_commits: replaced_commits - .into_iter() - .map(|(old, new)| (old.into(), new.into())) - .collect(), - } - } - } - - /// UI type for moving a commit. - #[derive(Debug, Serialize)] - #[cfg_attr(feature = "export-schema", derive(schemars::JsonSchema))] - #[serde(rename_all = "camelCase")] - pub struct UICommitMoveResult { - /// Commits that have been replaced as a side-effect of the move. - /// Maps `oldId → newId`. - #[cfg_attr( - feature = "export-schema", - schemars(with = "std::collections::BTreeMap") - )] - pub replaced_commits: std::collections::BTreeMap, - } - #[cfg(feature = "export-schema")] - but_schemars::register_sdk_type!(UICommitMoveResult); - - impl From for UICommitMoveResult { - fn from(value: CommitMoveResult) -> Self { - let CommitMoveResult { replaced_commits } = value; - - Self { - replaced_commits: replaced_commits - .into_iter() - .map(|(old, new)| (old.into(), new.into())) - .collect(), - } - } - } -} - -/// Rewords a commit -/// -/// Returns the result including the new commit ID and any replaced commits. -#[but_api(json::UICommitRewordResult)] -#[instrument(err(Debug))] -pub fn commit_reword_only( - ctx: &mut but_ctx::Context, - commit_id: gix::ObjectId, - message: BString, -) -> anyhow::Result { - let (_guard, repo, ws, _) = ctx.workspace_and_db()?; - let editor = ws.graph.to_editor(&repo)?; - - let (outcome, edited_commit_selector) = - but_workspace::commit::reword(editor, commit_id, message.as_bstr())?; - - let outcome = outcome.materialize()?; - let id = outcome.lookup_pick(edited_commit_selector)?; - let replaced_commits = outcome.history.commit_mappings(); - - Ok(CommitRewordResult { - new_commit: id, - replaced_commits, - }) -} - -/// Rewords a commit, with oplog support. -/// -/// Returns the result including the new commit ID and any replaced commits. -#[but_api(napi, json::UICommitRewordResult)] -#[instrument(err(Debug))] -pub fn commit_reword( - ctx: &mut but_ctx::Context, - commit_id: gix::ObjectId, - message: BString, -) -> anyhow::Result { - // NOTE: since this is optional by nature, the same would be true if snapshotting/undo would be disabled via `ctx` app settings, for instance. - let maybe_oplog_entry = but_oplog::UnmaterializedOplogSnapshot::from_details( - ctx, - SnapshotDetails::new(OperationKind::UpdateCommitMessage), - ) - .ok(); - - let res = commit_reword_only(ctx, commit_id, message); - if let Some(snapshot) = maybe_oplog_entry.filter(|_| res.is_ok()) { - let mut guard = ctx.exclusive_worktree_access(); - snapshot.commit(ctx, guard.write_permission()).ok(); - }; - res -} - -/// UI types for commit insertion -pub mod ui { - use serde::{Deserialize, Serialize}; - - #[derive(Debug, Clone, Serialize, Deserialize)] - #[cfg_attr(feature = "export-schema", derive(schemars::JsonSchema))] - #[serde(rename_all = "camelCase", tag = "type", content = "subject")] - /// Specifies a location, usually used to either have something inserted - /// relative to it, or for the selected object to actually be replaced. - pub enum RelativeTo { - /// Relative to a commit - #[serde(with = "but_serde::object_id")] - #[cfg_attr(feature = "export-schema", schemars(with = "String"))] - Commit(gix::ObjectId), - /// Relative to a reference - #[serde(with = "but_serde::fullname_lossy")] - #[cfg_attr(feature = "export-schema", schemars(with = "String"))] - Reference(gix::refs::FullName), - /// Relative to a reference, this time with teeth - #[cfg_attr( - feature = "export-schema", - schemars(schema_with = "but_schemars::fullname_bytes") - )] - ReferenceBytes(gix::refs::FullName), - } - #[cfg(feature = "export-schema")] - but_schemars::register_sdk_type!(RelativeTo); - - impl<'a> From<&'a RelativeTo> for but_rebase::graph_rebase::mutate::RelativeTo<'a> { - fn from(value: &'a RelativeTo) -> Self { - match value { - RelativeTo::Commit(c) => Self::Commit(*c), - RelativeTo::Reference(r) | RelativeTo::ReferenceBytes(r) => { - Self::Reference(r.as_ref()) - } - } - } - } -} - -/// Inserts a blank commit relative to either a commit or a reference -/// -/// Returns the result including the new commit ID and any replaced commits. -#[but_api(json::UICommitInsertBlankResult)] -#[instrument(err(Debug))] -pub fn commit_insert_blank_only( - ctx: &mut but_ctx::Context, - relative_to: ui::RelativeTo, - side: InsertSide, -) -> anyhow::Result { - let mut guard = ctx.exclusive_worktree_access(); - commit_insert_blank_only_impl(ctx, relative_to, side, guard.write_permission()) -} - -/// Implementation of inserting a blank commit relative to either a commit or a reference -/// -/// Returns the result including the new commit ID and any replaced commits. -pub(crate) fn commit_insert_blank_only_impl( - ctx: &mut but_ctx::Context, - relative_to: ui::RelativeTo, - side: InsertSide, - perm: &mut RepoExclusive, -) -> anyhow::Result { - let meta = ctx.meta()?; - let (repo, mut ws, _) = ctx.workspace_mut_and_db_with_perm(perm)?; - let editor = ws.graph.to_editor(&repo)?; - - let relative_to: RelativeTo = (&relative_to).into(); - - let (outcome, blank_commit_selector) = - but_workspace::commit::insert_blank_commit(editor, side, relative_to)?; - - let outcome = outcome.materialize()?; - let id = outcome.lookup_pick(blank_commit_selector)?; - let replaced_commits = outcome.history.commit_mappings(); - - // Play it safe and refresh the workspace - who knows how the context is used after this. - ws.refresh_from_head(&repo, &meta)?; - - Ok(CommitInsertBlankResult { - new_commit: id, - replaced_commits, - }) -} - -/// Inserts a blank commit relative to either a commit or a reference, with oplog support -/// -/// Returns the result including the new commit ID and any replaced commits. -#[but_api(napi, json::UICommitInsertBlankResult)] -#[instrument(err(Debug))] -pub fn commit_insert_blank( - ctx: &mut but_ctx::Context, - relative_to: ui::RelativeTo, - side: InsertSide, -) -> anyhow::Result { - let maybe_oplog_entry = but_oplog::UnmaterializedOplogSnapshot::from_details( - ctx, - SnapshotDetails::new(OperationKind::InsertBlankCommit), - ) - .ok(); - - let mut guard = ctx.exclusive_worktree_access(); - let res = commit_insert_blank_only_impl(ctx, relative_to, side, guard.write_permission()); - if let Some(snapshot) = maybe_oplog_entry.filter(|_| res.is_ok()) { - snapshot.commit(ctx, guard.write_permission()).ok(); - }; - res -} - -/// Creates and inserts a commit relative to either a commit or a reference. -#[but_api(json::UICommitCreateResult)] -#[instrument(err(Debug))] -pub fn commit_create_only( - ctx: &mut but_ctx::Context, - relative_to: ui::RelativeTo, - side: InsertSide, - changes: Vec, - message: String, -) -> anyhow::Result { - let context_lines = ctx.settings.context_lines; - let mut guard = ctx.exclusive_worktree_access(); - commit_create_only_impl( - ctx, - relative_to, - side, - changes, - message, - context_lines, - guard.write_permission(), - ) -} - -/// Creates and inserts a commit relative to either a commit or a reference. -pub(crate) fn commit_create_only_impl( - ctx: &mut but_ctx::Context, - relative_to: ui::RelativeTo, - side: InsertSide, - changes: Vec, - message: String, - context_lines: u32, - perm: &mut RepoExclusive, -) -> anyhow::Result { - let meta = ctx.meta()?; - let (repo, mut ws, _) = ctx.workspace_mut_and_db_with_perm(perm)?; - let editor = ws.graph.to_editor(&repo)?; - let relative_to: RelativeTo = (&relative_to).into(); - - let but_workspace::commit::CommitCreateOutcome { - rebase, - commit_selector, - rejected_specs, - } = but_workspace::commit::commit_create( - editor, - changes, - relative_to, - side, - &message, - context_lines, - )?; - - let (new_commit, replaced_commits) = match commit_selector { - Some(commit_selector) => { - let materialized = rebase.materialize()?; - let new_commit = materialized.lookup_pick(commit_selector)?; - let replaced_commits = materialized.history.commit_mappings(); - (Some(new_commit), replaced_commits) - } - None => (None, BTreeMap::new()), - }; - - ws.refresh_from_head(&repo, &meta)?; - - Ok(CommitCreateResult { - new_commit, - rejected_specs, - replaced_commits, - }) -} - -/// Creates and inserts a commit relative to either a commit or a reference, with oplog support. -#[but_api(napi, json::UICommitCreateResult)] -#[instrument(err(Debug))] -pub fn commit_create( - ctx: &mut but_ctx::Context, - relative_to: ui::RelativeTo, - side: InsertSide, - changes: Vec, - message: String, -) -> anyhow::Result { - let context_lines = ctx.settings.context_lines; - let maybe_oplog_entry = but_oplog::UnmaterializedOplogSnapshot::from_details( - ctx, - SnapshotDetails::new(OperationKind::CreateCommit), - ) - .ok(); - - let mut guard = ctx.exclusive_worktree_access(); - let res = commit_create_only_impl( - ctx, - relative_to, - side, - changes, - message, - context_lines, - guard.write_permission(), - ); - if let Some(snapshot) = maybe_oplog_entry.filter(|_| res.is_ok()) { - snapshot.commit(ctx, guard.write_permission()).ok(); - }; - res -} - -/// Amends an existing commit with selected changes. -#[but_api(json::UICommitCreateResult)] -#[instrument(err(Debug))] -pub fn commit_amend_only( - ctx: &mut but_ctx::Context, - commit_id: gix::ObjectId, - changes: Vec, -) -> anyhow::Result { - let context_lines = ctx.settings.context_lines; - let mut guard = ctx.exclusive_worktree_access(); - commit_amend_only_impl( - ctx, - commit_id, - changes, - context_lines, - guard.write_permission(), - ) -} - -/// Amends an existing commit with selected changes. -pub(crate) fn commit_amend_only_impl( - ctx: &mut but_ctx::Context, - commit_id: gix::ObjectId, - changes: Vec, - context_lines: u32, - perm: &mut RepoExclusive, -) -> anyhow::Result { - let meta = ctx.meta()?; - let (repo, mut ws, _) = ctx.workspace_mut_and_db_with_perm(perm)?; - let editor = ws.graph.to_editor(&repo)?; - - let but_workspace::commit::CommitAmendOutcome { - rebase, - commit_selector, - rejected_specs, - } = but_workspace::commit::commit_amend(editor, commit_id, changes, context_lines)?; - - let (new_commit, replaced_commits) = match commit_selector { - Some(commit_selector) => { - let materialized = rebase.materialize()?; - let new_commit = materialized.lookup_pick(commit_selector)?; - let replaced_commits = materialized.history.commit_mappings(); - (Some(new_commit), replaced_commits) - } - None => (None, BTreeMap::new()), - }; - - ws.refresh_from_head(&repo, &meta)?; - - Ok(CommitCreateResult { - new_commit, - rejected_specs, - replaced_commits, - }) -} - -/// Amends an existing commit with selected changes, with oplog support. -#[but_api(napi, json::UICommitCreateResult)] -#[instrument(err(Debug))] -pub fn commit_amend( - ctx: &mut but_ctx::Context, - commit_id: gix::ObjectId, - changes: Vec, -) -> anyhow::Result { - let context_lines = ctx.settings.context_lines; - let maybe_oplog_entry = but_oplog::UnmaterializedOplogSnapshot::from_details( - ctx, - SnapshotDetails::new(OperationKind::AmendCommit), - ) - .ok(); - - let mut guard = ctx.exclusive_worktree_access(); - let res = commit_amend_only_impl( - ctx, - commit_id, - changes, - context_lines, - guard.write_permission(), - ); - if let Some(snapshot) = maybe_oplog_entry.filter(|_| res.is_ok()) { - snapshot.commit(ctx, guard.write_permission()).ok(); - }; - res -} - -/// Moves changes between two commits -/// -/// Returns where the source and destination commits were mapped to. -/// -/// TODO(CTO): Create a way of extracting _all_ mapped commits. Copoilot, have -/// made linear ticket GB-980 for this. I will do this in a follow up PR. Please -/// don't complain. -#[but_api(json::UIMoveChangesResult)] -#[instrument(err(Debug))] -pub fn commit_move_changes_between_only( - ctx: &mut but_ctx::Context, - source_commit_id: gix::ObjectId, - destination_commit_id: gix::ObjectId, - changes: Vec, -) -> anyhow::Result { - let context_lines = ctx.settings.context_lines; - let meta = ctx.meta()?; - let (_guard, repo, mut ws, _) = ctx.workspace_mut_and_db()?; - let editor = ws.graph.to_editor(&repo)?; - - let outcome = but_workspace::commit::move_changes_between_commits( - editor, - source_commit_id, - destination_commit_id, - changes, - context_lines, - )?; - let materialized = outcome.rebase.materialize()?; - - ws.refresh_from_head(&repo, &meta)?; - - Ok(MoveChangesResult { - replaced_commits: materialized.history.commit_mappings(), - }) -} - -/// Moves changes between two commits -/// -/// Returns where the source and destination commits were mapped to. -#[but_api(napi, json::UIMoveChangesResult)] -#[instrument(err(Debug))] -pub fn commit_move_changes_between( - ctx: &mut but_ctx::Context, - source_commit_id: gix::ObjectId, - destination_commit_id: gix::ObjectId, - changes: Vec, -) -> anyhow::Result { - let maybe_oplog_entry = but_oplog::UnmaterializedOplogSnapshot::from_details( - ctx, - SnapshotDetails::new(OperationKind::MoveCommitFile), - ) - .ok(); - - let res = self::commit_move_changes_between_only( - ctx, - source_commit_id, - destination_commit_id, - changes, - ); - if let Some(snapshot) = maybe_oplog_entry.filter(|_| res.is_ok()) { - let mut guard = ctx.exclusive_worktree_access(); - snapshot.commit(ctx, guard.write_permission()).ok(); - }; - res -} - -/// Moves commit, no snapshots. No strings attached. -/// -/// Returns the replaced that resulted from the operation. -pub fn commit_move_only( - ctx: &mut but_ctx::Context, - subject_commit_id: gix::ObjectId, - relative_to: ui::RelativeTo, - side: InsertSide, -) -> anyhow::Result { - let meta = ctx.meta()?; - let (_guard, repo, mut ws, _) = ctx.workspace_mut_and_db()?; - let editor = ws.graph.to_editor(&repo)?; - - let relative_to: RelativeTo = (&relative_to).into(); - - let rebase = - but_workspace::commit::move_commit(editor, &ws, subject_commit_id, relative_to, side)?; - - let materialized = rebase.materialize()?; - ws.refresh_from_head(&repo, &meta)?; - - Ok(CommitMoveResult { - replaced_commits: materialized.history.commit_mappings(), - }) -} - -/// Moves a commit within or across stacks. -/// -/// Returns the replaced that resulted from the operation. -#[but_api(napi, json::UICommitMoveResult)] -#[instrument(err(Debug))] -pub fn commit_move( - ctx: &mut but_ctx::Context, - subject_commit_id: gix::ObjectId, - relative_to: ui::RelativeTo, - side: InsertSide, -) -> anyhow::Result { - let maybe_oplog_entry = but_oplog::UnmaterializedOplogSnapshot::from_details( - ctx, - SnapshotDetails::new(OperationKind::MoveCommit), - ) - .ok(); - - let res = commit_move_only(ctx, subject_commit_id, relative_to, side); - if let Some(snapshot) = maybe_oplog_entry.filter(|_| res.is_ok()) { - let mut guard = ctx.exclusive_worktree_access(); - snapshot.commit(ctx, guard.write_permission()).ok(); - }; - res -} - -/// Uncommits changes from a commit (removes them from the commit tree) without -/// performing a checkout. -/// -/// This has the practical effect of leaving the changes that were in the commit -/// as uncommitted changes in the worktree. -/// -/// If `assign_to` is provided, the newly uncommitted changes will be assigned -/// to the specified stack. -#[but_api(json::UIMoveChangesResult)] -#[instrument(err(Debug))] -pub fn commit_uncommit_changes_only( - ctx: &mut but_ctx::Context, - commit_id: gix::ObjectId, - changes: Vec, - assign_to: Option, -) -> anyhow::Result { - let context_lines = ctx.settings.context_lines; - let meta = ctx.meta()?; - let (_guard, repo, mut ws, mut db) = ctx.workspace_mut_and_db_mut()?; - - let before_assignments = if assign_to.is_some() { - let (assignments, _) = but_hunk_assignment::assignments_with_fallback( - db.hunk_assignments_mut()?, - &repo, - &ws, - false, - None::>, - None, - context_lines, - )?; - Some(assignments) - } else { - None - }; - - let editor = ws.graph.to_editor(&repo)?; - let outcome = - but_workspace::commit::uncommit_changes(editor, commit_id, changes, context_lines)?; - - let materialized = outcome.rebase.materialize_without_checkout()?; - - ws.refresh_from_head(&repo, &meta)?; - if let (Some(before_assignments), Some(stack_id)) = (before_assignments, assign_to) { - let (after_assignments, _) = but_hunk_assignment::assignments_with_fallback( - db.hunk_assignments_mut()?, - &repo, - &ws, - false, - None::>, - None, - context_lines, - )?; - - let before_ids: HashSet<_> = before_assignments - .into_iter() - .filter_map(|a| a.id) - .collect(); - - let to_assign: Vec<_> = after_assignments - .into_iter() - .filter(|a| a.id.is_some_and(|id| !before_ids.contains(&id))) - .map(|a| HunkAssignmentRequest { - hunk_header: a.hunk_header, - path_bytes: a.path_bytes, - stack_id: Some(stack_id), - }) - .collect(); - - but_hunk_assignment::assign( - db.hunk_assignments_mut()?, - &repo, - &ws, - to_assign, - None, - context_lines, - )?; - } - - Ok(MoveChangesResult { - replaced_commits: materialized.history.commit_mappings(), - }) -} - -/// Uncommits changes from a commit, with oplog and optional assign_to support -/// -/// If `assign_to` is provided, the newly uncommitted changes will be assigned -/// to the specified stack. -#[but_api(napi, json::UIMoveChangesResult)] -#[instrument(err(Debug))] -pub fn commit_uncommit_changes( - ctx: &mut but_ctx::Context, - commit_id: gix::ObjectId, - changes: Vec, - assign_to: Option, -) -> anyhow::Result { - let maybe_oplog_entry = but_oplog::UnmaterializedOplogSnapshot::from_details( - ctx, - SnapshotDetails::new(OperationKind::DiscardChanges), - ) - .ok(); - - let res = commit_uncommit_changes_only(ctx, commit_id, changes, assign_to); - - if let Some(snapshot) = maybe_oplog_entry.filter(|_| res.is_ok()) { - let mut guard = ctx.exclusive_worktree_access(); - snapshot.commit(ctx, guard.write_permission()).ok(); - }; - - res -} diff --git a/crates/but-api/src/commit/amend.rs b/crates/but-api/src/commit/amend.rs new file mode 100644 index 0000000000..b6cb1c1670 --- /dev/null +++ b/crates/but-api/src/commit/amend.rs @@ -0,0 +1,94 @@ +use std::collections::BTreeMap; + +use but_api_macros::but_api; +use but_core::{DiffSpec, sync::RepoExclusive}; +use but_oplog::legacy::{OperationKind, SnapshotDetails}; +use but_rebase::graph_rebase::{GraphExt, LookupStep as _}; +use tracing::instrument; + +use super::types::CommitCreateResult; + +/// Amends an existing commit with selected changes. +#[but_api(crate::commit::json::UICommitCreateResult)] +#[instrument(err(Debug))] +pub fn commit_amend_only( + ctx: &mut but_ctx::Context, + commit_id: gix::ObjectId, + changes: Vec, +) -> anyhow::Result { + let context_lines = ctx.settings.context_lines; + let mut guard = ctx.exclusive_worktree_access(); + commit_amend_only_impl( + ctx, + commit_id, + changes, + context_lines, + guard.write_permission(), + ) +} + +/// Amends an existing commit with selected changes. +pub(crate) fn commit_amend_only_impl( + ctx: &mut but_ctx::Context, + commit_id: gix::ObjectId, + changes: Vec, + context_lines: u32, + perm: &mut RepoExclusive, +) -> anyhow::Result { + let meta = ctx.meta()?; + let (repo, mut ws, _) = ctx.workspace_mut_and_db_with_perm(perm)?; + let editor = ws.graph.to_editor(&repo)?; + + let but_workspace::commit::CommitAmendOutcome { + rebase, + commit_selector, + rejected_specs, + } = but_workspace::commit::commit_amend(editor, commit_id, changes, context_lines)?; + + let (new_commit, replaced_commits) = match commit_selector { + Some(commit_selector) => { + let materialized = rebase.materialize()?; + let new_commit = materialized.lookup_pick(commit_selector)?; + let replaced_commits = materialized.history.commit_mappings(); + (Some(new_commit), replaced_commits) + } + None => (None, BTreeMap::new()), + }; + + ws.refresh_from_head(&repo, &meta)?; + + Ok(CommitCreateResult { + new_commit, + rejected_specs, + replaced_commits, + }) +} + +/// Amends an existing commit with selected changes, with oplog support. +#[but_api(napi, crate::commit::json::UICommitCreateResult)] +#[instrument(err(Debug))] +pub fn commit_amend( + ctx: &mut but_ctx::Context, + commit_id: gix::ObjectId, + changes: Vec, +) -> anyhow::Result { + let context_lines = ctx.settings.context_lines; + let maybe_oplog_entry = but_oplog::UnmaterializedOplogSnapshot::from_details( + ctx, + SnapshotDetails::new(OperationKind::AmendCommit), + ) + .ok(); + + let mut guard = ctx.exclusive_worktree_access(); + let res = commit_amend_only_impl( + ctx, + commit_id, + changes, + context_lines, + guard.write_permission(), + ); + if let Some(snapshot) = maybe_oplog_entry.filter(|_| res.is_ok()) { + snapshot.commit(ctx, guard.write_permission()).ok(); + }; + res +} diff --git a/crates/but-api/src/commit/create.rs b/crates/but-api/src/commit/create.rs new file mode 100644 index 0000000000..c360ba8155 --- /dev/null +++ b/crates/but-api/src/commit/create.rs @@ -0,0 +1,115 @@ +use std::collections::BTreeMap; + +use but_api_macros::but_api; +use but_core::{DiffSpec, sync::RepoExclusive}; +use but_oplog::legacy::{OperationKind, SnapshotDetails}; +use but_rebase::graph_rebase::{ + GraphExt, LookupStep as _, + mutate::{InsertSide, RelativeTo}, +}; +use tracing::instrument; + +use super::types::CommitCreateResult; + +/// Creates and inserts a commit relative to either a commit or a reference. +#[but_api(crate::commit::json::UICommitCreateResult)] +#[instrument(err(Debug))] +pub fn commit_create_only( + ctx: &mut but_ctx::Context, + relative_to: crate::commit::json::RelativeTo, + side: InsertSide, + changes: Vec, + message: String, +) -> anyhow::Result { + let context_lines = ctx.settings.context_lines; + let mut guard = ctx.exclusive_worktree_access(); + commit_create_only_impl( + ctx, + relative_to, + side, + changes, + message, + context_lines, + guard.write_permission(), + ) +} + +/// Creates and inserts a commit relative to either a commit or a reference. +pub(crate) fn commit_create_only_impl( + ctx: &mut but_ctx::Context, + relative_to: crate::commit::json::RelativeTo, + side: InsertSide, + changes: Vec, + message: String, + context_lines: u32, + perm: &mut RepoExclusive, +) -> anyhow::Result { + let meta = ctx.meta()?; + let (repo, mut ws, _) = ctx.workspace_mut_and_db_with_perm(perm)?; + let editor = ws.graph.to_editor(&repo)?; + let relative_to: RelativeTo = (&relative_to).into(); + + let but_workspace::commit::CommitCreateOutcome { + rebase, + commit_selector, + rejected_specs, + } = but_workspace::commit::commit_create( + editor, + changes, + relative_to, + side, + &message, + context_lines, + )?; + + let (new_commit, replaced_commits) = match commit_selector { + Some(commit_selector) => { + let materialized = rebase.materialize()?; + let new_commit = materialized.lookup_pick(commit_selector)?; + let replaced_commits = materialized.history.commit_mappings(); + (Some(new_commit), replaced_commits) + } + None => (None, BTreeMap::new()), + }; + + ws.refresh_from_head(&repo, &meta)?; + + Ok(CommitCreateResult { + new_commit, + rejected_specs, + replaced_commits, + }) +} + +/// Creates and inserts a commit relative to either a commit or a reference, with oplog support. +#[but_api(napi, crate::commit::json::UICommitCreateResult)] +#[instrument(err(Debug))] +pub fn commit_create( + ctx: &mut but_ctx::Context, + relative_to: crate::commit::json::RelativeTo, + side: InsertSide, + changes: Vec, + message: String, +) -> anyhow::Result { + let context_lines = ctx.settings.context_lines; + let maybe_oplog_entry = but_oplog::UnmaterializedOplogSnapshot::from_details( + ctx, + SnapshotDetails::new(OperationKind::CreateCommit), + ) + .ok(); + + let mut guard = ctx.exclusive_worktree_access(); + let res = commit_create_only_impl( + ctx, + relative_to, + side, + changes, + message, + context_lines, + guard.write_permission(), + ); + if let Some(snapshot) = maybe_oplog_entry.filter(|_| res.is_ok()) { + snapshot.commit(ctx, guard.write_permission()).ok(); + }; + res +} diff --git a/crates/but-api/src/commit/insert_blank.rs b/crates/but-api/src/commit/insert_blank.rs new file mode 100644 index 0000000000..0eede1cc9c --- /dev/null +++ b/crates/but-api/src/commit/insert_blank.rs @@ -0,0 +1,77 @@ +use but_api_macros::but_api; +use but_core::sync::RepoExclusive; +use but_oplog::legacy::{OperationKind, SnapshotDetails}; +use but_rebase::graph_rebase::{ + GraphExt, LookupStep as _, + mutate::{InsertSide, RelativeTo}, +}; +use tracing::instrument; + +use super::types::CommitInsertBlankResult; + +/// Inserts a blank commit relative to either a commit or a reference +/// +/// Returns the result including the new commit ID and any replaced commits. +#[but_api(crate::commit::json::UICommitInsertBlankResult)] +#[instrument(err(Debug))] +pub fn commit_insert_blank_only( + ctx: &mut but_ctx::Context, + relative_to: crate::commit::json::RelativeTo, + side: InsertSide, +) -> anyhow::Result { + let mut guard = ctx.exclusive_worktree_access(); + commit_insert_blank_only_impl(ctx, relative_to, side, guard.write_permission()) +} + +/// Implementation of inserting a blank commit relative to either a commit or a reference +/// +/// Returns the result including the new commit ID and any replaced commits. +pub(crate) fn commit_insert_blank_only_impl( + ctx: &mut but_ctx::Context, + relative_to: crate::commit::json::RelativeTo, + side: InsertSide, + perm: &mut RepoExclusive, +) -> anyhow::Result { + let meta = ctx.meta()?; + let (repo, mut ws, _) = ctx.workspace_mut_and_db_with_perm(perm)?; + let editor = ws.graph.to_editor(&repo)?; + let relative_to: RelativeTo = (&relative_to).into(); + + let (outcome, blank_commit_selector) = + but_workspace::commit::insert_blank_commit(editor, side, relative_to)?; + + let outcome = outcome.materialize()?; + let id = outcome.lookup_pick(blank_commit_selector)?; + let replaced_commits = outcome.history.commit_mappings(); + + ws.refresh_from_head(&repo, &meta)?; + + Ok(CommitInsertBlankResult { + new_commit: id, + replaced_commits, + }) +} + +/// Inserts a blank commit relative to either a commit or a reference, with oplog support +/// +/// Returns the result including the new commit ID and any replaced commits. +#[but_api(napi, crate::commit::json::UICommitInsertBlankResult)] +#[instrument(err(Debug))] +pub fn commit_insert_blank( + ctx: &mut but_ctx::Context, + relative_to: crate::commit::json::RelativeTo, + side: InsertSide, +) -> anyhow::Result { + let maybe_oplog_entry = but_oplog::UnmaterializedOplogSnapshot::from_details( + ctx, + SnapshotDetails::new(OperationKind::InsertBlankCommit), + ) + .ok(); + + let mut guard = ctx.exclusive_worktree_access(); + let res = commit_insert_blank_only_impl(ctx, relative_to, side, guard.write_permission()); + if let Some(snapshot) = maybe_oplog_entry.filter(|_| res.is_ok()) { + snapshot.commit(ctx, guard.write_permission()).ok(); + }; + res +} diff --git a/crates/but-api/src/commit/json.rs b/crates/but-api/src/commit/json.rs new file mode 100644 index 0000000000..13b86bd115 --- /dev/null +++ b/crates/but-api/src/commit/json.rs @@ -0,0 +1,218 @@ +use serde::{Deserialize, Serialize}; + +use crate::json::HexHash; + +use super::types::{ + CommitCreateResult, CommitInsertBlankResult, CommitMoveResult, CommitRewordResult, + MoveChangesResult, +}; + +/// UI type for a move changes between commits result. +#[derive(Debug, Serialize)] +#[cfg_attr(feature = "export-schema", derive(schemars::JsonSchema))] +#[serde(rename_all = "camelCase")] +pub struct UIMoveChangesResult { + /// Commits that have been mapped from one thing to another. + /// Maps `oldId -> newId`. + #[cfg_attr( + feature = "export-schema", + schemars(with = "std::collections::BTreeMap") + )] + pub replaced_commits: std::collections::BTreeMap, +} +#[cfg(feature = "export-schema")] +but_schemars::register_sdk_type!(UIMoveChangesResult); + +impl From for UIMoveChangesResult { + fn from(value: MoveChangesResult) -> Self { + let MoveChangesResult { replaced_commits } = value; + + Self { + replaced_commits: replaced_commits + .into_iter() + .map(|(old, new)| (old.into(), new.into())) + .collect(), + } + } +} + +/// UI type for creating a commit in the rebase graph. +#[derive(Debug, Serialize)] +#[cfg_attr(feature = "export-schema", derive(schemars::JsonSchema))] +#[serde(rename_all = "camelCase")] +pub struct UICommitCreateResult { + /// The new commit if one was created. + #[cfg_attr(feature = "export-schema", schemars(with = "Option"))] + pub new_commit: Option, + /// Paths that contained at least one rejected hunk, matching legacy rejection reporting semantics. + #[cfg_attr(feature = "export-schema", schemars(with = "Vec<(String, String)>"))] + pub paths_to_rejected_changes: Vec<( + but_core::tree::create_tree::RejectionReason, + but_serde::BStringForFrontend, + )>, + /// Commits that have been replaced as a side-effect of the create/amend. + /// Maps `oldId -> newId`. + #[cfg_attr( + feature = "export-schema", + schemars(with = "std::collections::BTreeMap") + )] + pub replaced_commits: std::collections::BTreeMap, +} +#[cfg(feature = "export-schema")] +but_schemars::register_sdk_type!(UICommitCreateResult); + +impl From for UICommitCreateResult { + fn from(value: CommitCreateResult) -> Self { + let CommitCreateResult { + new_commit, + rejected_specs, + replaced_commits, + } = value; + + Self { + new_commit: new_commit.map(Into::into), + paths_to_rejected_changes: rejected_specs + .into_iter() + .map(|(reason, diff)| (reason, diff.path.into())) + .collect(), + replaced_commits: replaced_commits + .into_iter() + .map(|(old, new)| (old.into(), new.into())) + .collect(), + } + } +} + +/// UI type for rewording a commit. +#[derive(Debug, Serialize)] +#[cfg_attr(feature = "export-schema", derive(schemars::JsonSchema))] +#[serde(rename_all = "camelCase")] +pub struct UICommitRewordResult { + /// The new commit ID after rewording. + #[cfg_attr(feature = "export-schema", schemars(with = "String"))] + pub new_commit: HexHash, + /// Commits that have been replaced as a side-effect of the reword. + /// Maps `oldId -> newId`. + #[cfg_attr( + feature = "export-schema", + schemars(with = "std::collections::BTreeMap") + )] + pub replaced_commits: std::collections::BTreeMap, +} +#[cfg(feature = "export-schema")] +but_schemars::register_sdk_type!(UICommitRewordResult); + +impl From for UICommitRewordResult { + fn from(value: CommitRewordResult) -> Self { + let CommitRewordResult { + new_commit, + replaced_commits, + } = value; + + Self { + new_commit: new_commit.into(), + replaced_commits: replaced_commits + .into_iter() + .map(|(old, new)| (old.into(), new.into())) + .collect(), + } + } +} + +/// UI type for inserting a blank commit. +#[derive(Debug, Serialize)] +#[cfg_attr(feature = "export-schema", derive(schemars::JsonSchema))] +#[serde(rename_all = "camelCase")] +pub struct UICommitInsertBlankResult { + /// The new blank commit ID. + #[cfg_attr(feature = "export-schema", schemars(with = "String"))] + pub new_commit: HexHash, + /// Commits that have been replaced as a side-effect of the insertion. + /// Maps `oldId -> newId`. + #[cfg_attr( + feature = "export-schema", + schemars(with = "std::collections::BTreeMap") + )] + pub replaced_commits: std::collections::BTreeMap, +} +#[cfg(feature = "export-schema")] +but_schemars::register_sdk_type!(UICommitInsertBlankResult); + +impl From for UICommitInsertBlankResult { + fn from(value: CommitInsertBlankResult) -> Self { + let CommitInsertBlankResult { + new_commit, + replaced_commits, + } = value; + + Self { + new_commit: new_commit.into(), + replaced_commits: replaced_commits + .into_iter() + .map(|(old, new)| (old.into(), new.into())) + .collect(), + } + } +} + +/// UI type for moving a commit. +#[derive(Debug, Serialize)] +#[cfg_attr(feature = "export-schema", derive(schemars::JsonSchema))] +#[serde(rename_all = "camelCase")] +pub struct UICommitMoveResult { + /// Commits that have been replaced as a side-effect of the move. + /// Maps `oldId -> newId`. + #[cfg_attr( + feature = "export-schema", + schemars(with = "std::collections::BTreeMap") + )] + pub replaced_commits: std::collections::BTreeMap, +} +#[cfg(feature = "export-schema")] +but_schemars::register_sdk_type!(UICommitMoveResult); + +impl From for UICommitMoveResult { + fn from(value: CommitMoveResult) -> Self { + let CommitMoveResult { replaced_commits } = value; + + Self { + replaced_commits: replaced_commits + .into_iter() + .map(|(old, new)| (old.into(), new.into())) + .collect(), + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "export-schema", derive(schemars::JsonSchema))] +#[serde(rename_all = "camelCase", tag = "type", content = "subject")] +/// Specifies a location, usually used to either have something inserted +/// relative to it, or for the selected object to actually be replaced. +pub enum RelativeTo { + /// Relative to a commit + #[serde(with = "but_serde::object_id")] + #[cfg_attr(feature = "export-schema", schemars(with = "String"))] + Commit(gix::ObjectId), + /// Relative to a reference + #[serde(with = "but_serde::fullname_lossy")] + #[cfg_attr(feature = "export-schema", schemars(with = "String"))] + Reference(gix::refs::FullName), + /// Relative to a reference, this time with teeth + #[cfg_attr( + feature = "export-schema", + schemars(schema_with = "but_schemars::fullname_bytes") + )] + ReferenceBytes(gix::refs::FullName), +} +#[cfg(feature = "export-schema")] +but_schemars::register_sdk_type!(RelativeTo); + +impl<'a> From<&'a RelativeTo> for but_rebase::graph_rebase::mutate::RelativeTo<'a> { + fn from(value: &'a RelativeTo) -> Self { + match value { + RelativeTo::Commit(c) => Self::Commit(*c), + RelativeTo::Reference(r) | RelativeTo::ReferenceBytes(r) => Self::Reference(r.as_ref()), + } + } +} diff --git a/crates/but-api/src/commit/mod.rs b/crates/but-api/src/commit/mod.rs new file mode 100644 index 0000000000..131931ba69 --- /dev/null +++ b/crates/but-api/src/commit/mod.rs @@ -0,0 +1,22 @@ +//! Functions that operate on commits. + +/// Endpoints for amending commits. +pub mod amend; +/// Endpoints for creating commits. +pub mod create; +/// Endpoints for inserting blank commits. +pub mod insert_blank; +/// JSON transport types for commit APIs. +// Ideally, this would be private and only used for transport, but we don't have +// a good parameter mapping solution at the minute. +pub mod json; +/// Endpoints for moving changes. +pub mod move_changes; +/// Endpoints for moving commits. +pub mod move_commit; +/// Endpoints for rewording commits. +pub mod reword; +/// Shared types for commit APIs. +pub mod types; +/// Endpoints for uncommitting commits. +pub mod uncommit_changes; diff --git a/crates/but-api/src/commit/move_changes.rs b/crates/but-api/src/commit/move_changes.rs new file mode 100644 index 0000000000..198c4b97b5 --- /dev/null +++ b/crates/but-api/src/commit/move_changes.rs @@ -0,0 +1,64 @@ +use but_api_macros::but_api; +use but_oplog::legacy::{OperationKind, SnapshotDetails}; +use but_rebase::graph_rebase::GraphExt; +use tracing::instrument; + +use super::types::MoveChangesResult; + +/// Moves changes between two commits +/// +/// Returns where the source and destination commits were mapped to. +#[but_api(crate::commit::json::UIMoveChangesResult)] +#[instrument(err(Debug))] +pub fn commit_move_changes_between_only( + ctx: &mut but_ctx::Context, + source_commit_id: gix::ObjectId, + destination_commit_id: gix::ObjectId, + changes: Vec, +) -> anyhow::Result { + let context_lines = ctx.settings.context_lines; + let meta = ctx.meta()?; + let (_guard, repo, mut ws, _) = ctx.workspace_mut_and_db()?; + let editor = ws.graph.to_editor(&repo)?; + + let outcome = but_workspace::commit::move_changes_between_commits( + editor, + source_commit_id, + destination_commit_id, + changes, + context_lines, + )?; + let materialized = outcome.rebase.materialize()?; + + ws.refresh_from_head(&repo, &meta)?; + + Ok(MoveChangesResult { + replaced_commits: materialized.history.commit_mappings(), + }) +} + +/// Moves changes between two commits +/// +/// Returns where the source and destination commits were mapped to. +#[but_api(napi, crate::commit::json::UIMoveChangesResult)] +#[instrument(err(Debug))] +pub fn commit_move_changes_between( + ctx: &mut but_ctx::Context, + source_commit_id: gix::ObjectId, + destination_commit_id: gix::ObjectId, + changes: Vec, +) -> anyhow::Result { + let maybe_oplog_entry = but_oplog::UnmaterializedOplogSnapshot::from_details( + ctx, + SnapshotDetails::new(OperationKind::MoveCommitFile), + ) + .ok(); + + let res = + commit_move_changes_between_only(ctx, source_commit_id, destination_commit_id, changes); + if let Some(snapshot) = maybe_oplog_entry.filter(|_| res.is_ok()) { + let mut guard = ctx.exclusive_worktree_access(); + snapshot.commit(ctx, guard.write_permission()).ok(); + }; + res +} diff --git a/crates/but-api/src/commit/move_commit.rs b/crates/but-api/src/commit/move_commit.rs new file mode 100644 index 0000000000..90554fb082 --- /dev/null +++ b/crates/but-api/src/commit/move_commit.rs @@ -0,0 +1,59 @@ +use but_oplog::legacy::{OperationKind, SnapshotDetails}; +use but_rebase::graph_rebase::{ + GraphExt, + mutate::{InsertSide, RelativeTo}, +}; +use tracing::instrument; + +use super::types::CommitMoveResult; + +/// Moves commit, no snapshots. No strings attached. +/// +/// Returns the replaced that resulted from the operation. +pub fn commit_move_only( + ctx: &mut but_ctx::Context, + subject_commit_id: gix::ObjectId, + relative_to: crate::commit::json::RelativeTo, + side: InsertSide, +) -> anyhow::Result { + let meta = ctx.meta()?; + let (_guard, repo, mut ws, _) = ctx.workspace_mut_and_db()?; + let editor = ws.graph.to_editor(&repo)?; + + let relative_to: RelativeTo = (&relative_to).into(); + + let rebase = + but_workspace::commit::move_commit(editor, &ws, subject_commit_id, relative_to, side)?; + + let materialized = rebase.materialize()?; + ws.refresh_from_head(&repo, &meta)?; + + Ok(CommitMoveResult { + replaced_commits: materialized.history.commit_mappings(), + }) +} + +/// Moves a commit within or across stacks. +/// +/// Returns the replaced that resulted from the operation. +#[but_api_macros::but_api(napi, crate::commit::json::UICommitMoveResult)] +#[instrument(err(Debug))] +pub fn commit_move( + ctx: &mut but_ctx::Context, + subject_commit_id: gix::ObjectId, + relative_to: crate::commit::json::RelativeTo, + side: InsertSide, +) -> anyhow::Result { + let maybe_oplog_entry = but_oplog::UnmaterializedOplogSnapshot::from_details( + ctx, + SnapshotDetails::new(OperationKind::MoveCommit), + ) + .ok(); + + let res = commit_move_only(ctx, subject_commit_id, relative_to, side); + if let Some(snapshot) = maybe_oplog_entry.filter(|_| res.is_ok()) { + let mut guard = ctx.exclusive_worktree_access(); + snapshot.commit(ctx, guard.write_permission()).ok(); + }; + res +} diff --git a/crates/but-api/src/commit/reword.rs b/crates/but-api/src/commit/reword.rs new file mode 100644 index 0000000000..0b4e1dcfe9 --- /dev/null +++ b/crates/but-api/src/commit/reword.rs @@ -0,0 +1,57 @@ +use bstr::{BString, ByteSlice}; +use but_api_macros::but_api; +use but_oplog::legacy::{OperationKind, SnapshotDetails}; +use but_rebase::graph_rebase::{GraphExt, LookupStep as _}; +use tracing::instrument; + +use super::types::CommitRewordResult; + +/// Rewords a commit +/// +/// Returns the result including the new commit ID and any replaced commits. +#[but_api(crate::commit::json::UICommitRewordResult)] +#[instrument(err(Debug))] +pub fn commit_reword_only( + ctx: &mut but_ctx::Context, + commit_id: gix::ObjectId, + message: BString, +) -> anyhow::Result { + let (_guard, repo, ws, _) = ctx.workspace_and_db()?; + let editor = ws.graph.to_editor(&repo)?; + + let (outcome, edited_commit_selector) = + but_workspace::commit::reword(editor, commit_id, message.as_bstr())?; + + let outcome = outcome.materialize()?; + let id = outcome.lookup_pick(edited_commit_selector)?; + let replaced_commits = outcome.history.commit_mappings(); + + Ok(CommitRewordResult { + new_commit: id, + replaced_commits, + }) +} + +/// Rewords a commit, with oplog support. +/// +/// Returns the result including the new commit ID and any replaced commits. +#[but_api(napi, crate::commit::json::UICommitRewordResult)] +#[instrument(err(Debug))] +pub fn commit_reword( + ctx: &mut but_ctx::Context, + commit_id: gix::ObjectId, + message: BString, +) -> anyhow::Result { + let maybe_oplog_entry = but_oplog::UnmaterializedOplogSnapshot::from_details( + ctx, + SnapshotDetails::new(OperationKind::UpdateCommitMessage), + ) + .ok(); + + let res = commit_reword_only(ctx, commit_id, message); + if let Some(snapshot) = maybe_oplog_entry.filter(|_| res.is_ok()) { + let mut guard = ctx.exclusive_worktree_access(); + snapshot.commit(ctx, guard.write_permission()).ok(); + }; + res +} diff --git a/crates/but-api/src/commit/types.rs b/crates/but-api/src/commit/types.rs new file mode 100644 index 0000000000..dd7454913b --- /dev/null +++ b/crates/but-api/src/commit/types.rs @@ -0,0 +1,41 @@ +use std::collections::BTreeMap; + +use but_core::{DiffSpec, tree::create_tree::RejectionReason}; + +/// Outcome after creating a commit. +pub struct CommitCreateResult { + /// If the commit was successfully created. This should only be none if all the DiffSpecs were rejected. + pub new_commit: Option, + /// Any specs that failed to be committed. + pub rejected_specs: Vec<(RejectionReason, DiffSpec)>, + /// Commits that were replaced by this operation. Maps `old_id -> new_id`. + pub replaced_commits: BTreeMap, +} + +/// Outcome after moving changes between commits. +pub struct MoveChangesResult { + /// Commits that were replaced by this operation. Maps `old_id -> new_id`. + pub replaced_commits: BTreeMap, +} + +/// Outcome after rewording a commit. +pub struct CommitRewordResult { + /// The ID of the newly created commit with the updated message. + pub new_commit: gix::ObjectId, + /// Commits that were replaced by this operation. Maps `old_id -> new_id`. + pub replaced_commits: BTreeMap, +} + +/// Outcome of moving a commit. +pub struct CommitMoveResult { + /// Commits that were replaced by this operation. Maps `old_id -> new_id`. + pub replaced_commits: BTreeMap, +} + +/// Outcome after inserting a blank commit. +pub struct CommitInsertBlankResult { + /// The ID of the newly inserted blank commit. + pub new_commit: gix::ObjectId, + /// Commits that were replaced by this operation. Maps `old_id -> new_id`. + pub replaced_commits: BTreeMap, +} diff --git a/crates/but-api/src/commit/uncommit_changes.rs b/crates/but-api/src/commit/uncommit_changes.rs new file mode 100644 index 0000000000..63019318e7 --- /dev/null +++ b/crates/but-api/src/commit/uncommit_changes.rs @@ -0,0 +1,120 @@ +use std::collections::HashSet; + +use but_api_macros::but_api; +use but_hunk_assignment::HunkAssignmentRequest; +use but_oplog::legacy::{OperationKind, SnapshotDetails}; +use but_rebase::graph_rebase::GraphExt as _; +use tracing::instrument; + +use crate::commit::types::MoveChangesResult; + +/// Uncommits changes from a commit (removes them from the commit tree) without +/// performing a checkout. +/// +/// This has the practical effect of leaving the changes that were in the commit +/// as uncommitted changes in the worktree. +/// +/// If `assign_to` is provided, the newly uncommitted changes will be assigned +/// to the specified stack. +#[but_api(crate::commit::json::UIMoveChangesResult)] +#[instrument(err(Debug))] +pub fn commit_uncommit_changes_only( + ctx: &mut but_ctx::Context, + commit_id: gix::ObjectId, + changes: Vec, + assign_to: Option, +) -> anyhow::Result { + let context_lines = ctx.settings.context_lines; + let meta = ctx.meta()?; + let (_guard, repo, mut ws, mut db) = ctx.workspace_mut_and_db_mut()?; + + let before_assignments = if assign_to.is_some() { + let (assignments, _) = but_hunk_assignment::assignments_with_fallback( + db.hunk_assignments_mut()?, + &repo, + &ws, + false, + None::>, + None, + context_lines, + )?; + Some(assignments) + } else { + None + }; + + let editor = ws.graph.to_editor(&repo)?; + let outcome = + but_workspace::commit::uncommit_changes(editor, commit_id, changes, context_lines)?; + + let materialized = outcome.rebase.materialize_without_checkout()?; + + ws.refresh_from_head(&repo, &meta)?; + if let (Some(before_assignments), Some(stack_id)) = (before_assignments, assign_to) { + let (after_assignments, _) = but_hunk_assignment::assignments_with_fallback( + db.hunk_assignments_mut()?, + &repo, + &ws, + false, + None::>, + None, + context_lines, + )?; + + let before_ids: HashSet<_> = before_assignments + .into_iter() + .filter_map(|a| a.id) + .collect(); + + let to_assign: Vec<_> = after_assignments + .into_iter() + .filter(|a| a.id.is_some_and(|id| !before_ids.contains(&id))) + .map(|a| HunkAssignmentRequest { + hunk_header: a.hunk_header, + path_bytes: a.path_bytes, + stack_id: Some(stack_id), + }) + .collect(); + + but_hunk_assignment::assign( + db.hunk_assignments_mut()?, + &repo, + &ws, + to_assign, + None, + context_lines, + )?; + } + + Ok(MoveChangesResult { + replaced_commits: materialized.history.commit_mappings(), + }) +} + +/// Uncommits changes from a commit, with oplog and optional assign_to support +/// +/// If `assign_to` is provided, the newly uncommitted changes will be assigned +/// to the specified stack. +#[but_api(napi, crate::commit::json::UIMoveChangesResult)] +#[instrument(err(Debug))] +pub fn commit_uncommit_changes( + ctx: &mut but_ctx::Context, + commit_id: gix::ObjectId, + changes: Vec, + assign_to: Option, +) -> anyhow::Result { + let maybe_oplog_entry = but_oplog::UnmaterializedOplogSnapshot::from_details( + ctx, + SnapshotDetails::new(OperationKind::DiscardChanges), + ) + .ok(); + + let res = commit_uncommit_changes_only(ctx, commit_id, changes, assign_to); + + if let Some(snapshot) = maybe_oplog_entry.filter(|_| res.is_ok()) { + let mut guard = ctx.exclusive_worktree_access(); + snapshot.commit(ctx, guard.write_permission()).ok(); + }; + + res +} diff --git a/crates/but-api/src/legacy/absorb.rs b/crates/but-api/src/legacy/absorb.rs index a23769e7b4..dfb1d08eeb 100644 --- a/crates/but-api/src/legacy/absorb.rs +++ b/crates/but-api/src/legacy/absorb.rs @@ -23,7 +23,7 @@ use itertools::Itertools; use tracing::instrument; use crate::{ - commit::commit_insert_blank_only_impl, + commit::insert_blank::commit_insert_blank_only_impl, legacy::{diff::changes_in_worktree, workspace::amend_commit_and_count_failures}, }; @@ -292,7 +292,7 @@ fn determine_target_commit( .ok_or_else(|| anyhow::anyhow!("Stack has no branches"))?; commit_insert_blank_only_impl( ctx, - crate::commit::ui::RelativeTo::Reference(branch.reference.clone()), + crate::commit::json::RelativeTo::Reference(branch.reference.clone()), InsertSide::Below, perm, )?; @@ -327,7 +327,7 @@ fn determine_target_commit( .ok_or_else(|| anyhow::anyhow!("Stack has no branches"))?; commit_insert_blank_only_impl( ctx, - crate::commit::ui::RelativeTo::Reference(branch.reference.clone()), + crate::commit::json::RelativeTo::Reference(branch.reference.clone()), InsertSide::Below, perm, )?; diff --git a/crates/but-server/src/lib.rs b/crates/but-server/src/lib.rs index 10fb7f7ee4..aa9217ff3f 100644 --- a/crates/but-server/src/lib.rs +++ b/crates/but-server/src/lib.rs @@ -435,10 +435,6 @@ pub async fn run() { "/reorder_stack", but_post(legacy::virtual_branches::reorder_stack_cmd), ) - .route( - "/commit_insert_blank", - but_post(commit::commit_insert_blank_cmd), - ) .route( "/list_branches", but_post(legacy::virtual_branches::list_branches_cmd), @@ -657,16 +653,26 @@ pub async fn run() { "/claude_maybe_create_prompt_dir", but_post(legacy::claude::claude_maybe_create_prompt_dir_cmd), ) - .route("/commit_reword", but_post(commit::commit_reword_cmd)) - .route("/commit_create", but_post(commit::commit_create_cmd)) - .route("/commit_amend", but_post(commit::commit_amend_cmd)) + .route( + "/commit_insert_blank", + but_post(commit::insert_blank::commit_insert_blank_cmd), + ) + .route( + "/commit_reword", + but_post(commit::reword::commit_reword_cmd), + ) + .route( + "/commit_create", + but_post(commit::create::commit_create_cmd), + ) + .route("/commit_amend", but_post(commit::amend::commit_amend_cmd)) .route( "/commit_move_changes_between", - but_post(commit::commit_move_changes_between_cmd), + but_post(commit::move_changes::commit_move_changes_between_cmd), ) .route( "/commit_uncommit_changes", - but_post(commit::commit_uncommit_changes_cmd), + but_post(commit::uncommit_changes::commit_uncommit_changes_cmd), ) .route("/build_type", but_post(platform::build_type_cmd)); diff --git a/crates/but/src/command/commit/file.rs b/crates/but/src/command/commit/file.rs index bc973d4095..65aafe073e 100644 --- a/crates/but/src/command/commit/file.rs +++ b/crates/but/src/command/commit/file.rs @@ -26,7 +26,12 @@ pub fn commited_file_to_another_commit( .collect::>() }; - but_api::commit::commit_move_changes_between_only(ctx, source_id, target_id, relevant_changes)?; + but_api::commit::move_changes::commit_move_changes_between_only( + ctx, + source_id, + target_id, + relevant_changes, + )?; update_workspace_commit(ctx, false)?; @@ -63,7 +68,12 @@ pub fn uncommit_file( .collect::>() }; - but_api::commit::commit_uncommit_changes_only(ctx, source_id, relevant_changes, assign_to)?; + but_api::commit::uncommit_changes::commit_uncommit_changes_only( + ctx, + source_id, + relevant_changes, + assign_to, + )?; update_workspace_commit(ctx, false)?; diff --git a/crates/but/src/command/commit/move.rs b/crates/but/src/command/commit/move.rs index d3401a84dc..2d766fc868 100644 --- a/crates/but/src/command/commit/move.rs +++ b/crates/but/src/command/commit/move.rs @@ -1,5 +1,5 @@ use anyhow::bail; -use but_api::commit::ui::RelativeTo; +use but_api::commit::json::RelativeTo; use but_ctx::Context; use but_rebase::graph_rebase::mutate::InsertSide; use colored::Colorize; @@ -163,7 +163,7 @@ pub fn move_commit_to_commit( return Ok(()); } - but_api::commit::commit_move(ctx, source, RelativeTo::Commit(target), side)?; + but_api::commit::move_commit::commit_move(ctx, source, RelativeTo::Commit(target), side)?; if let Some(out) = out.for_human() { let repo = ctx.repo.get()?; @@ -189,7 +189,7 @@ pub fn move_commit_to_branch( out: &mut OutputChannel, ) -> Result<(), anyhow::Error> { let target_full_name = gix::refs::FullName::try_from(format!("refs/heads/{target_branch}"))?; - but_api::commit::commit_move( + but_api::commit::move_commit::commit_move( ctx, source, RelativeTo::Reference(target_full_name), diff --git a/crates/but/src/command/legacy/commit.rs b/crates/but/src/command/legacy/commit.rs index 23f5efe1e0..522dee2e04 100644 --- a/crates/but/src/command/legacy/commit.rs +++ b/crates/but/src/command/legacy/commit.rs @@ -3,7 +3,7 @@ use std::{collections::BTreeMap, fmt::Write as _}; use anyhow::{Context, Result, bail}; use bstr::{BString, ByteSlice}; use but_api::{ - commit::{commit_create, commit_insert_blank}, + commit::{create::commit_create, insert_blank::commit_insert_blank}, legacy::{diff, repo, workspace}, }; use but_core::{DiffSpec, ui::TreeChange}; @@ -61,7 +61,7 @@ pub(crate) fn insert_blank_commit( }; commit_insert_blank( ctx, - but_api::commit::ui::RelativeTo::Commit(*oid), + but_api::commit::json::RelativeTo::Commit(*oid), insert_side, )?; format!("Created blank commit {position_desc} commit {short_oid}") @@ -73,7 +73,7 @@ pub(crate) fn insert_blank_commit( }; commit_insert_blank( ctx, - but_api::commit::ui::RelativeTo::Reference(reference.name), + but_api::commit::json::RelativeTo::Reference(reference.name), insert_side, )?; match insert_side { @@ -463,7 +463,7 @@ pub(crate) fn commit( // Insert relative to the branch reference itself so only that branch tip is advanced. let outcome = commit_create( ctx, - but_api::commit::ui::RelativeTo::Reference(target_branch.reference.clone()), + but_api::commit::json::RelativeTo::Reference(target_branch.reference.clone()), InsertSide::Below, diff_specs, final_commit_message, diff --git a/crates/but/src/command/legacy/reword.rs b/crates/but/src/command/legacy/reword.rs index 6ffad713b9..d3e75b45c9 100644 --- a/crates/but/src/command/legacy/reword.rs +++ b/crates/but/src/command/legacy/reword.rs @@ -183,8 +183,11 @@ fn edit_commit_message_by_id_and_reword_commit( .filter(|new_message| should_update_commit_message(¤t_message, new_message)); if let Some(new_message) = new_message { - let new_commit_oid = - but_api::commit::commit_reword_only(ctx, commit_oid, BString::from(new_message))?; + let new_commit_oid = but_api::commit::reword::commit_reword_only( + ctx, + commit_oid, + BString::from(new_message), + )?; if let Some(out) = out.for_human() { let repo = ctx.repo.get()?; diff --git a/crates/but/src/command/legacy/rub/squash.rs b/crates/but/src/command/legacy/rub/squash.rs index eeee710f3e..16449b223f 100644 --- a/crates/but/src/command/legacy/rub/squash.rs +++ b/crates/but/src/command/legacy/rub/squash.rs @@ -258,12 +258,13 @@ fn squash_commits_internal( destination_message.unwrap_or_default(), user_summary, )?; - but_api::commit::commit_reword_only(ctx, new_commit_oid, BString::from(ai_message))? + but_api::commit::reword::commit_reword_only(ctx, new_commit_oid, BString::from(ai_message))? .new_commit } else if let Some(msg) = custom_message { - but_api::commit::commit_reword_only(ctx, new_commit_oid, BString::from(msg))?.new_commit + but_api::commit::reword::commit_reword_only(ctx, new_commit_oid, BString::from(msg))? + .new_commit } else if let Some(target_msg) = target_message { - but_api::commit::commit_reword_only(ctx, new_commit_oid, BString::from(target_msg))? + but_api::commit::reword::commit_reword_only(ctx, new_commit_oid, BString::from(target_msg))? .new_commit } else { new_commit_oid diff --git a/crates/but/src/command/legacy/status/tui/mod.rs b/crates/but/src/command/legacy/status/tui/mod.rs index bcba59e9b2..03fa567cd7 100644 --- a/crates/but/src/command/legacy/status/tui/mod.rs +++ b/crates/but/src/command/legacy/status/tui/mod.rs @@ -8,7 +8,7 @@ use std::{ use anyhow::Context as _; use bstr::BString; use but_api::{ - commit::{commit_insert_blank, ui::RelativeTo}, + commit::{insert_blank::commit_insert_blank, json::RelativeTo}, diff::ComputeLineStats, }; use but_ctx::Context; @@ -510,7 +510,7 @@ impl App { } let reword_result = - but_api::commit::commit_reword_only(ctx, commit_id, BString::from(new_message)) + but_api::commit::reword::commit_reword_only(ctx, commit_id, BString::from(new_message)) .with_context(|| format!("failed to reword {}", commit_id.to_hex_with_len(7)))?; messages.push(Message::Reload(Some(SelectAfterReload::Commit( @@ -590,9 +590,12 @@ impl App { return Ok(()); } - let reword_result = - but_api::commit::commit_reword_only(ctx, *commit_id, BString::from(new_message)) - .with_context(|| format!("failed to reword {}", commit_id.to_hex_with_len(7)))?; + let reword_result = but_api::commit::reword::commit_reword_only( + ctx, + *commit_id, + BString::from(new_message), + ) + .with_context(|| format!("failed to reword {}", commit_id.to_hex_with_len(7)))?; messages.extend([ Message::EnterNormalMode, diff --git a/crates/gitbutler-tauri/src/main.rs b/crates/gitbutler-tauri/src/main.rs index 7630654305..f3c6d1505a 100644 --- a/crates/gitbutler-tauri/src/main.rs +++ b/crates/gitbutler-tauri/src/main.rs @@ -539,12 +539,12 @@ fn main() -> anyhow::Result<()> { irc::irc_start_working_files_broadcast, #[cfg(feature = "irc")] irc::irc_stop_working_files_broadcast, - commit::tauri_commit_reword::commit_reword, - commit::tauri_commit_insert_blank::commit_insert_blank, - commit::tauri_commit_create::commit_create, - commit::tauri_commit_amend::commit_amend, - commit::tauri_commit_move_changes_between::commit_move_changes_between, - commit::tauri_commit_uncommit_changes::commit_uncommit_changes, + commit::reword::tauri_commit_reword::commit_reword, + commit::insert_blank::tauri_commit_insert_blank::commit_insert_blank, + commit::create::tauri_commit_create::commit_create, + commit::amend::tauri_commit_amend::commit_amend, + commit::move_changes::tauri_commit_move_changes_between::commit_move_changes_between, + commit::uncommit_changes::tauri_commit_uncommit_changes::commit_uncommit_changes, platform::tauri_build_type::build_type, ]) .menu(move |handle| menu::build(handle, &app_settings_for_menu))