diff --git a/Cargo.lock b/Cargo.lock index dfd1d5e83e..afc41bbadc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -776,6 +776,7 @@ dependencies = [ "but-graph", "but-hunk-assignment", "but-hunk-dependency", + "but-rules", "but-settings", "but-tools", "but-workspace", @@ -787,6 +788,7 @@ dependencies = [ "gitbutler-branch", "gitbutler-branch-actions", "gitbutler-command-context", + "gitbutler-commit", "gitbutler-oxidize", "gitbutler-project", "gitbutler-secret", @@ -794,6 +796,7 @@ dependencies = [ "gitbutler-stack", "gix", "posthog-rs", + "regex", "rmcp", "serde", "serde_json", @@ -1122,6 +1125,7 @@ dependencies = [ "gitbutler-command-context", "gitbutler-project", "gitbutler-stack", + "gix", "itertools", "regex", "serde", diff --git a/crates/but-rules/Cargo.toml b/crates/but-rules/Cargo.toml index 1c2787db72..b1ac325136 100644 --- a/crates/but-rules/Cargo.toml +++ b/crates/but-rules/Cargo.toml @@ -14,6 +14,7 @@ anyhow = "1.0.98" itertools.workspace = true serde.workspace = true regex = "1.11.1" +gix = { workspace = true } chrono = { version = "0.4.41", features = [] } serde_regex = "1.1.0" serde_json = "1.0.142" diff --git a/crates/but-rules/src/handler.rs b/crates/but-rules/src/handler.rs index ac1ee06994..76400c3de2 100644 --- a/crates/but-rules/src/handler.rs +++ b/crates/but-rules/src/handler.rs @@ -1,7 +1,7 @@ use but_graph::VirtualBranchesTomlMetadata; use but_hunk_assignment::{HunkAssignment, assign, assignments_to_requests}; use but_hunk_dependency::ui::HunkDependencies; -use but_workspace::{StackId, StacksFilter, ui::StackEntry}; +use but_workspace::{DiffSpec, StackId, StacksFilter, commit_engine, ui::StackEntry}; use gitbutler_command_context::CommandContext; use itertools::Itertools; use std::str::FromStr; @@ -26,6 +26,9 @@ pub fn process_workspace_rules( matches!( &r.action, super::Action::Explicit(super::Operation::Assign { .. }) + ) || matches!( + &r.action, + super::Action::Explicit(super::Operation::Amend { .. }) ) }) .collect_vec(); @@ -60,6 +63,10 @@ pub fn process_workspace_rules( handle_assign(ctx, assignments, dependencies.as_ref()).unwrap_or_default(); } } + super::Action::Explicit(super::Operation::Amend { change_id }) => { + let assignments = matching(assignments, rule.filters.clone()); + handle_amend(ctx, assignments, change_id).unwrap_or_default(); + } _ => continue, }; } @@ -137,6 +144,60 @@ fn handle_assign( } } +fn handle_amend( + ctx: &mut CommandContext, + assignments: Vec, + change_id: String, +) -> anyhow::Result<()> { + let changes: Vec = assignments.into_iter().map(|a| a.into()).collect(); + let project = ctx.project(); + let mut guard = project.exclusive_worktree_access(); + let repo = but_core::open_repo_for_merging(project.worktree_path())?; + + let meta = VirtualBranchesTomlMetadata::from_path( + ctx.project().gb_dir().join("virtual_branches.toml"), + )?; + let ref_info_options = but_workspace::ref_info::Options { + expensive_commit_info: true, + traversal: meta.graph_options(), + }; + let info = but_workspace::head_info(&repo, &meta, ref_info_options)?; + let mut commit_id: Option = None; + 'outer: for stack in info.stacks { + for segment in stack.segments { + for commit in segment.commits { + if Some(change_id.clone()) == commit.change_id.map(|c| c.to_string()) { + commit_id = Some(commit.id); + break 'outer; + } + } + } + } + + let commit_id = commit_id.ok_or_else(|| { + anyhow::anyhow!( + "No commit with Change-Id {} found in the current workspace", + change_id + ) + })?; + + commit_engine::create_commit_and_update_refs_with_project( + &repo, + project, + None, + commit_engine::Destination::AmendCommit { + commit_id, + // TODO: Expose this in the UI for 'edit message' functionality. + new_message: None, + }, + None, + changes, + ctx.app_settings().context_lines, + guard.write_permission(), + )?; + Ok(()) +} + fn matching(wt_assignments: &[HunkAssignment], filters: Vec) -> Vec { if filters.is_empty() { return wt_assignments.to_vec(); diff --git a/crates/but-rules/src/lib.rs b/crates/but-rules/src/lib.rs index 1c6efa5e19..32e7fb40c3 100644 --- a/crates/but-rules/src/lib.rs +++ b/crates/but-rules/src/lib.rs @@ -46,6 +46,14 @@ impl WorkspaceRule { } } + pub fn target_commit_id(&self) -> Option { + if let Action::Explicit(Operation::Amend { change_id }) = &self.action { + Some(change_id.clone()) + } else { + None + } + } + pub fn id(&self) -> String { self.id.clone() } @@ -139,7 +147,7 @@ pub enum Operation { /// Assign the matched changes to a specific stack ID. Assign { target: StackTarget }, /// Amend the matched changes into a specific commit. - Amend { commit_id: String }, + Amend { change_id: String }, /// Create a new commit with the matched changes on a specific branch. NewCommit { branch_name: String }, } @@ -292,7 +300,7 @@ pub fn list_rules(ctx: &mut CommandContext) -> anyhow::Result Ok(rules) } -fn process_rules(ctx: &mut CommandContext) -> anyhow::Result<()> { +pub fn process_rules(ctx: &mut CommandContext) -> anyhow::Result<()> { let wt_changes = but_core::diff::worktree_changes(&ctx.gix_repo()?)?; let dependencies = hunk_dependencies_for_workspace_changes_by_worktree_dir( diff --git a/crates/but/Cargo.toml b/crates/but/Cargo.toml index 4875ebee22..4c9d6faf5f 100644 --- a/crates/but/Cargo.toml +++ b/crates/but/Cargo.toml @@ -30,6 +30,7 @@ anyhow.workspace = true rmcp.workspace = true command-group = { version = "5.0.1", features = ["with-tokio"] } sysinfo = "0.36.0" +regex = "1.11.1" gitbutler-project.workspace = true gix.workspace = true but-core.workspace = true @@ -42,9 +43,11 @@ but-hunk-assignment.workspace = true but-hunk-dependency.workspace = true but-claude.workspace = true but-tools.workspace = true +but-rules.workspace = true gitbutler-command-context.workspace = true gitbutler-serde.workspace = true gitbutler-stack.workspace = true +gitbutler-commit.workspace = true gitbutler-branch-actions.workspace = true gitbutler-branch.workspace = true gitbutler-secret.workspace = true diff --git a/crates/but/src/args.rs b/crates/but/src/args.rs index 32b525390c..3232210dc0 100644 --- a/crates/but/src/args.rs +++ b/crates/but/src/args.rs @@ -42,6 +42,14 @@ For examples see `but rub --help`." /// The target entity to combine with the source target: String, }, + /// Creates or removes a rule for auto-assigning or auto-comitting + Mark { + /// The target entity that will be marked + target: String, + /// Deletes a mark + #[clap(long, short = 'd')] + delete: bool, + }, /// Starts up the MCP server. Mcp { /// Starts the internal MCP server which has more granular tools. diff --git a/crates/but/src/log/mod.rs b/crates/but/src/log/mod.rs index ea0a8710d1..a0e47c242c 100644 --- a/crates/but/src/log/mod.rs +++ b/crates/but/src/log/mod.rs @@ -14,14 +14,21 @@ use crate::id::CliId; pub(crate) fn commit_graph(repo_path: &Path, _json: bool) -> anyhow::Result<()> { let project = Project::from_path(repo_path).expect("Failed to create project from path"); let ctx = &mut CommandContext::open(&project, AppSettings::load_from_default_path_creating()?)?; + but_rules::process_rules(ctx).ok(); // TODO: this is doing double work (dependencies can be reused) let stacks = stacks(ctx)? .iter() - .filter_map(|s| s.id.map(|id| stack_details(ctx, id))) + .filter_map(|s| s.id.map(|id| stack_details(ctx, id).map(|d| (id, d)))) .filter_map(Result::ok) .collect::>(); let mut nesting = 0; - for (i, stack) in stacks.iter().enumerate() { + for (i, (stack_id, stack)) in stacks.iter().enumerate() { + let marked = crate::mark::stack_marked(ctx, *stack_id).unwrap_or_default(); + let mut mark = if marked { + Some("◀ Marked ▶".red().bold()) + } else { + None + }; let mut second_consecutive = false; let mut stacked = false; for branch in stack.branch_details.iter() { @@ -45,13 +52,15 @@ pub(crate) fn commit_graph(repo_path: &Path, _json: bool) -> anyhow::Result<()> .underline() .blue(); println!( - "{}{}{} [{}] {}", + "{}{}{} [{}] {} {}", "│ ".repeat(nesting), extra_space, line, branch.name.to_string().green().bold(), - id + id, + mark.clone().unwrap_or_default() ); + mark = None; // show this on the first branch in the stack for (j, commit) in branch.upstream_commits.iter().enumerate() { let time_string = chrono::DateTime::from_timestamp_millis(commit.created_at as i64) .ok_or(anyhow::anyhow!("Could not parse timestamp"))? @@ -83,6 +92,13 @@ pub(crate) fn commit_graph(repo_path: &Path, _json: bool) -> anyhow::Result<()> } } for commit in branch.commits.iter() { + let marked = + crate::mark::commit_marked(ctx, commit.id.to_string()).unwrap_or_default(); + let mark = if marked { + Some("◀ Marked ▶".red().bold()) + } else { + None + }; let state_str = match commit.state { but_workspace::ui::CommitState::LocalOnly => "{local}".normal(), but_workspace::ui::CommitState::LocalAndRemote(_) => "{pushed}".cyan(), @@ -98,14 +114,15 @@ pub(crate) fn commit_graph(repo_path: &Path, _json: bool) -> anyhow::Result<()> .format("%Y-%m-%d %H:%M:%S") .to_string(); println!( - "{}● {}{} {} {} {} {}", + "{}● {}{} {} {} {} {} {}", "│ ".repeat(nesting), &commit.id.to_string()[..2].blue().underline(), &commit.id.to_string()[2..7].blue(), state_str, conflicted_str, commit.author.name, - time_string.dimmed() + time_string.dimmed(), + mark.clone().unwrap_or_default() ); println!( "{}│ {}", diff --git a/crates/but/src/main.rs b/crates/but/src/main.rs index 38b60fa381..99ee3ed980 100644 --- a/crates/but/src/main.rs +++ b/crates/but/src/main.rs @@ -9,6 +9,7 @@ use but_claude::hooks::OutputAsJson; mod command; mod id; mod log; +mod mark; mod mcp; mod mcp_internal; mod metrics; @@ -98,6 +99,15 @@ async fn main() -> Result<()> { metrics_if_configured(app_settings, CommandName::Rub, props(start, &result)).ok(); Ok(()) } + Subcommands::Mark { target, delete } => { + let result = mark::handle(&args.current_dir, args.json, target, *delete) + .context("Can't mark this. Taaaa-na-na-na. Can't mark this."); + if let Err(e) = &result { + eprintln!("{} {}", e, e.root_cause()); + } + metrics_if_configured(app_settings, CommandName::Rub, props(start, &result)).ok(); + Ok(()) + } } } diff --git a/crates/but/src/mark/mod.rs b/crates/but/src/mark/mod.rs new file mode 100644 index 0000000000..2b705bf847 --- /dev/null +++ b/crates/but/src/mark/mod.rs @@ -0,0 +1,116 @@ +use std::{path::Path, str::FromStr}; + +use crate::rub::branch_name_to_stack_id; +use anyhow::bail; +use but_rules::Operation; +use but_settings::AppSettings; +use but_workspace::StackId; +use gitbutler_command_context::CommandContext; +use gitbutler_commit::commit_ext::CommitExt; +use gitbutler_project::Project; +pub(crate) fn handle( + repo_path: &Path, + _json: bool, + target_str: &str, + delete: bool, +) -> anyhow::Result<()> { + let project = Project::from_path(repo_path).expect("Failed to create project from path"); + let ctx = &mut CommandContext::open(&project, AppSettings::load_from_default_path_creating()?)?; + let target_result = crate::id::CliId::from_str(ctx, target_str)?; + if target_result.len() != 1 { + return Err(anyhow::anyhow!( + "Target {} is ambiguous: {:?}", + target_str, + target_result + )); + } + // Hack - delete all other rules + for rule in but_rules::list_rules(ctx)? { + but_rules::delete_rule(ctx, &rule.id())?; + } + match target_result[0].clone() { + crate::id::CliId::Branch { name } => mark_branch(ctx, name, delete), + crate::id::CliId::Commit { oid } => mark_commit(ctx, oid, delete), + _ => bail!("Nope"), + } +} + +fn mark_commit(ctx: &mut CommandContext, oid: gix::ObjectId, delete: bool) -> anyhow::Result<()> { + if delete { + let rules = but_rules::list_rules(ctx)?; + for rule in rules { + if rule.target_commit_id() == Some(oid.to_string()) { + but_rules::delete_rule(ctx, &rule.id())?; + } + } + println!("Mark was removed"); + return Ok(()); + } + let repo = ctx.gix_repo()?; + let commit = repo.find_commit(oid)?; + let change_id = commit.change_id().ok_or_else(|| { + anyhow::anyhow!("Commit {} does not have a Change-Id, cannot mark it", oid) + })?; + let action = but_rules::Action::Explicit(Operation::Amend { change_id }); + let req = but_rules::CreateRuleRequest { + trigger: but_rules::Trigger::FileSytemChange, + filters: vec![but_rules::Filter::PathMatchesRegex(regex::Regex::new( + ".*", + )?)], + action, + }; + but_rules::create_rule(ctx, req)?; + println!("Changes will be amended into commit → {}", &oid.to_string()); + Ok(()) +} + +fn mark_branch(ctx: &mut CommandContext, branch_name: String, delete: bool) -> anyhow::Result<()> { + let stack_id = branch_name_to_stack_id(ctx, Some(&branch_name))?; + if delete { + let rules = but_rules::list_rules(ctx)?; + for rule in rules { + if rule.target_stack_id() == stack_id.map(|s| s.to_string()) { + but_rules::delete_rule(ctx, &rule.id())?; + } + } + println!("Mark was removed"); + return Ok(()); + } + // TODO: if there are other marks of this kind, get rid of them + let stack_id = stack_id.expect("Cant find stack for this branch"); + let action = but_rules::Action::Explicit(Operation::Assign { + target: but_rules::StackTarget::StackId(stack_id.to_string()), + }); + let req = but_rules::CreateRuleRequest { + trigger: but_rules::Trigger::FileSytemChange, + filters: vec![but_rules::Filter::PathMatchesRegex(regex::Regex::new( + ".*", + )?)], + action, + }; + but_rules::create_rule(ctx, req)?; + println!("Changes will be assigned to → {}", branch_name); + Ok(()) +} + +pub(crate) fn stack_marked(ctx: &mut CommandContext, stack_id: StackId) -> anyhow::Result { + let rules = but_rules::list_rules(ctx)? + .iter() + .any(|r| r.target_stack_id() == Some(stack_id.to_string())); + Ok(rules) +} + +pub(crate) fn commit_marked(ctx: &mut CommandContext, commit_id: String) -> anyhow::Result { + let repo = ctx.gix_repo()?; + let commit = repo.find_commit(gix::ObjectId::from_str(&commit_id)?)?; + let change_id = commit.change_id().ok_or_else(|| { + anyhow::anyhow!( + "Commit {} does not have a Change-Id, cannot mark it", + commit_id + ) + })?; + let rules = but_rules::list_rules(ctx)? + .iter() + .any(|r| r.target_commit_id() == Some(change_id.clone())); + Ok(rules) +} diff --git a/crates/but/src/rub/mod.rs b/crates/but/src/rub/mod.rs index e0187242cf..abf21190fe 100644 --- a/crates/but/src/rub/mod.rs +++ b/crates/but/src/rub/mod.rs @@ -1,6 +1,7 @@ use std::path::Path; use anyhow::bail; +pub(crate) use assign::branch_name_to_stack_id; use but_settings::AppSettings; use colored::Colorize; use gitbutler_command_context::CommandContext; diff --git a/crates/but/src/status/mod.rs b/crates/but/src/status/mod.rs index f4388dbfb8..d4d1907fff 100644 --- a/crates/but/src/status/mod.rs +++ b/crates/but/src/status/mod.rs @@ -15,6 +15,7 @@ use crate::id::CliId; pub(crate) fn worktree(repo_path: &Path, _json: bool) -> anyhow::Result<()> { let project = Project::from_path(repo_path).expect("Failed to create project from path"); let ctx = &mut CommandContext::open(&project, AppSettings::load_from_default_path_creating()?)?; + but_rules::process_rules(ctx).ok(); // TODO: this is doing double work (dependencies can be reused) let stack_id_to_branch = crate::log::stacks(ctx)? .iter() @@ -51,12 +52,13 @@ pub(crate) fn worktree(repo_path: &Path, _json: bool) -> anyhow::Result<()> { } let unassigned = assignment::filter_by_stack_id(assignments_by_file.values(), &None); - print_group(None, unassigned, &changes)?; + print_group(None, unassigned, &changes, false)?; for (stack_id, branch) in &stack_id_to_branch { let filtered = assignment::filter_by_stack_id(assignments_by_file.values(), &Some(*stack_id)); - print_group(Some(branch.as_str()), filtered, &changes)?; + let marked = crate::mark::stack_marked(ctx, *stack_id).unwrap_or_default(); + print_group(Some(branch.as_str()), filtered, &changes, marked)?; } Ok(()) } @@ -65,6 +67,7 @@ pub fn print_group( group: Option<&str>, assignments: Vec, changes: &[TreeChange], + marked: bool, ) -> anyhow::Result<()> { let id = if let Some(group) = group { CliId::branch(group) @@ -77,7 +80,17 @@ pub fn print_group( let group = &group .map(|s| format!("[{}]", s)) .unwrap_or("".to_string()); - println!("{} {}", id, group.green().bold()); + let mark = if marked { + Some("◀ Marked ▶".red().bold()) + } else { + None + }; + println!( + "{} {} {}", + id, + group.green().bold(), + mark.unwrap_or_default() + ); for fa in assignments { let state = status_from_changes(changes, fa.path.clone()); let path = match state {