diff --git a/Cargo.lock b/Cargo.lock index ac04fe069a..4e1f49eb64 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", @@ -784,16 +785,25 @@ dependencies = [ "colored", "command-group", "dirs-next", + "git2", "gitbutler-branch", "gitbutler-branch-actions", "gitbutler-command-context", + "gitbutler-commit", + "gitbutler-diff", + "gitbutler-oplog", "gitbutler-oxidize", "gitbutler-project", + "gitbutler-reference", + "gitbutler-repo", "gitbutler-secret", "gitbutler-serde", "gitbutler-stack", + "gitbutler-user", "gix", "posthog-rs", + "regex", + "reqwest 0.12.23", "rmcp", "serde", "serde_json", @@ -1123,6 +1133,7 @@ dependencies = [ "gitbutler-command-context", "gitbutler-project", "gitbutler-stack", + "gix", "itertools", "regex", "serde", @@ -8139,6 +8150,7 @@ dependencies = [ "cookie", "cookie_store", "encoding_rs", + "futures-channel", "futures-core", "futures-util", "h2 0.4.10", diff --git a/crates/but-rules/Cargo.toml b/crates/but-rules/Cargo.toml index 9a390c224c..925a7d4f3d 100644 --- a/crates/but-rules/Cargo.toml +++ b/crates/but-rules/Cargo.toml @@ -14,6 +14,7 @@ anyhow = "1.0.99" itertools.workspace = true serde.workspace = true regex = "1.11.2" +gix = { workspace = true } chrono = { version = "0.4.41", features = [] } serde_regex = "1.1.0" serde_json = "1.0.143" 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-workspace/src/stacks.rs b/crates/but-workspace/src/stacks.rs index 2ea49cec99..33f3b0ccb9 100644 --- a/crates/but-workspace/src/stacks.rs +++ b/crates/but-workspace/src/stacks.rs @@ -167,20 +167,20 @@ pub fn stacks_v3( applied_stacks: &[branch::Stack], ) -> anyhow::Result> { let mut out = Vec::new(); + // Create a set of all ref names that are in applied stacks for efficient lookup + let applied_ref_names: std::collections::HashSet<_> = applied_stacks + .iter() + .flat_map(|stack| &stack.segments) + .filter_map(|segment| segment.ref_name.as_ref()) + .collect(); + for item in meta.iter() { let (ref_name, ref_meta) = item?; if !ref_meta.is::() { continue; }; - let is_applied = applied_stacks.iter().any(|stack| { - stack.segments.iter().any(|segment| { - segment - .ref_name - .as_ref() - .is_some_and(|name| name == &ref_name) - }) - }); - if is_applied { + // Check if this ref_name is in our applied_ref_names set + if applied_ref_names.contains(&ref_name) { continue; } @@ -222,21 +222,41 @@ pub fn stacks_v3( stacks: Vec, meta: &VirtualBranchesTomlMetadata, ) -> Vec { + use std::collections::HashSet; + let mut seen_ids = HashSet::new(); + stacks .into_iter() .filter_map(|stack| try_from_stack_v3(repo, stack, meta).ok()) + .filter(|entry| { + // Deduplicate by stack ID if present + match entry.id { + Some(id) => seen_ids.insert(id), + None => true, // Always include stacks without IDs + } + }) .collect() } - let unapplied_stacks = unapplied_stacks(repo, meta, &info.stacks)?; Ok(match filter { StacksFilter::InWorkspace => into_ui_stacks(repo, info.stacks, meta), StacksFilter::All => { + let unapplied_stacks = unapplied_stacks(repo, meta, &info.stacks)?; let mut all_stacks = unapplied_stacks; all_stacks.extend(into_ui_stacks(repo, info.stacks, meta)); - all_stacks + + // Deduplicate by ID across both applied and unapplied stacks + use std::collections::HashMap; + let mut deduped: HashMap, ui::StackEntry> = HashMap::new(); + for stack in all_stacks { + deduped.insert(stack.id, stack); + } + deduped.into_values().collect() + } + StacksFilter::Unapplied => { + let unapplied_stacks = unapplied_stacks(repo, meta, &info.stacks)?; + unapplied_stacks } - StacksFilter::Unapplied => unapplied_stacks, }) } diff --git a/crates/but/Cargo.toml b/crates/but/Cargo.toml index 8c249cb7cf..82c24a4362 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.37.0" +regex = "1.11.1" gitbutler-project.workspace = true gix.workspace = true but-core.workspace = true @@ -42,15 +43,24 @@ 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-reference.workspace = true gitbutler-secret.workspace = true gitbutler-oxidize.workspace = true +gitbutler-repo.workspace = true +gitbutler-user.workspace = true +gitbutler-oplog.workspace = true +gitbutler-diff.workspace = true +git2.workspace = true colored = "3.0.0" serde_json = "1.0.143" +reqwest = { version = "0.12", default-features = false, features = ["blocking", "rustls-tls"] } tracing.workspace = true tracing-subscriber = { version = "0.3", features = [ "env-filter", diff --git a/crates/but/src/args.rs b/crates/but/src/args.rs index 32b525390c..6e2808df00 100644 --- a/crates/but/src/args.rs +++ b/crates/but/src/args.rs @@ -17,9 +17,76 @@ pub struct Args { #[derive(Debug, clap::Subcommand)] pub enum Subcommands { /// Provides an overview of the Workspace commit graph. - Log, + Log { + /// Show only branch topology with commit counts instead of detailed commits + #[clap(long, short = 's')] + short: bool, + }, /// Overview of the oncommitted changes in the repository. - Status, + #[clap(alias = "st")] + Status { + /// Show base branch and behind count information + #[clap(long, short = 'b')] + base: bool, + /// Show modified files in each commit with shortcode IDs for rubbing + #[clap(long, short = 'f')] + files: bool, + }, + /// Overview with modified files in each commit (equivalent to `status -f`). + #[clap(alias = "stf", hide = true)] + StatusFiles { + /// Show base branch and behind count information + #[clap(long, short = 'b')] + base: bool, + }, + /// Display or set configuration values for the repository. + Config { + /// Configuration key to get or set (e.g., user.name, user.email) + key: Option, + /// Value to set (if provided, sets the key to this value) + value: Option, + }, + + /// Show operation history (last 20 entries). + Oplog { + /// Start from this oplog SHA instead of the head + #[clap(long)] + since: Option, + }, + /// Undo the last operation by reverting to the previous snapshot. + Undo, + /// Restore to a specific oplog snapshot. + Restore { + /// Oplog SHA to restore to + oplog_sha: String, + }, + + /// Commit changes to a stack. + Commit { + /// Commit message + #[clap(short = 'm', long = "message")] + message: Option, + /// Branch CLI ID or name to derive the stack to commit to + branch: Option, + /// Only commit assigned files, not unassigned files + #[clap(short = 'o', long = "only")] + only: bool, + }, + /// Insert a blank commit before the specified commit, or at the top of a stack. + New { + /// Commit ID to insert before, or branch ID to insert at top of stack + target: String, + }, + /// Edit the commit message of the specified commit. + Describe { + /// Commit ID to edit the message for + commit: String, + }, + /// Branch management operations. + Branch { + #[clap(subcommand)] + cmd: BranchSubcommands, + }, /// Combines two entities together to perform an operation. #[clap( @@ -42,6 +109,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. @@ -64,12 +139,45 @@ For examples see `but rub --help`." }, } +#[derive(Debug, clap::Subcommand)] +pub enum BranchSubcommands { + /// Create a new virtual branch. + New { + /// The name of the new branch + branch_name: String, + /// Optional branch ID or branch name to create a stacked branch from. + /// Can be a 2-character CLI ID (e.g., "ab") or a full branch name (e.g., "main", "feature/auth"). + /// Works with both virtual branches and regular Git branches. + id: Option, + }, + /// Unapply a virtual branch. + Unapply { + /// Branch ID or branch name to unapply. + /// Can be a 2-character CLI ID (e.g., "ab") or a full branch name. + branch_id: String, + }, +} + #[derive(Debug, Clone, Copy, clap::ValueEnum, Default)] pub enum CommandName { #[clap(alias = "log")] Log, #[clap(alias = "status")] Status, + #[clap(alias = "config")] + Config, + #[clap(alias = "oplog")] + Oplog, + #[clap(alias = "undo")] + Undo, + #[clap(alias = "restore")] + Restore, + #[clap(alias = "commit")] + Commit, + #[clap(alias = "new")] + New, + #[clap(alias = "describe")] + Describe, #[clap(alias = "rub")] Rub, #[clap( diff --git a/crates/but/src/branch/mod.rs b/crates/but/src/branch/mod.rs new file mode 100644 index 0000000000..7cf0e46d8e --- /dev/null +++ b/crates/but/src/branch/mod.rs @@ -0,0 +1,259 @@ +use but_settings::AppSettings; +use colored::Colorize; +use gitbutler_branch::BranchCreateRequest; +use gitbutler_branch_actions::{create_virtual_branch, unapply_stack}; +use gitbutler_command_context::CommandContext; +use gitbutler_oxidize::{ObjectIdExt, RepoExt}; +use gitbutler_project::Project; +use gitbutler_stack::VirtualBranchesHandle; +use std::path::Path; + +use crate::id::CliId; + +pub(crate) fn create_branch( + repo_path: &Path, + _json: bool, + branch_name: &str, + base_id: Option<&str>, +) -> anyhow::Result<()> { + let project = Project::from_path(repo_path)?; + let mut ctx = CommandContext::open(&project, AppSettings::load_from_default_path_creating()?)?; + + match base_id { + Some(id_str) => { + // First try to resolve as CLI ID + let cli_ids = CliId::from_str(&mut ctx, id_str)?; + + let target_branch_name = if !cli_ids.is_empty() { + if cli_ids.len() > 1 { + return Err(anyhow::anyhow!( + "Ambiguous ID '{}', matches multiple items", + id_str + )); + } + + // Get the branch CLI ID + let cli_id = &cli_ids[0]; + if !matches!(cli_id, CliId::Branch { .. }) { + return Err(anyhow::anyhow!( + "ID '{}' does not refer to a branch", + id_str + )); + } + + // Get the branch name from the CLI ID + match cli_id { + CliId::Branch { name } => name.clone(), + _ => unreachable!(), + } + } else { + // If no CLI ID matches, try treating it as a direct branch name + let repo = ctx.repo(); + + // Check if the branch exists as a local branch + if repo.find_branch(id_str, git2::BranchType::Local).is_ok() { + id_str.to_string() + } else { + return Err(anyhow::anyhow!( + "No branch found matching ID or name: {}", + id_str + )); + } + }; + + println!( + "Creating stacked branch '{}' based on branch {} ({})", + branch_name.green().bold(), + target_branch_name.cyan(), + id_str.blue().underline() + ); + + // Find the target stack and get its current head commit + let stacks = crate::log::stacks(&ctx)?; + let target_stack = stacks.iter().find(|s| { + s.heads + .iter() + .any(|head| head.name.to_string() == target_branch_name) + }); + + let target_stack = match target_stack { + Some(s) => s, + None => { + return Err(anyhow::anyhow!( + "No stack found for branch '{}'", + target_branch_name + )); + } + }; + + let target_stack_id = target_stack + .id + .ok_or_else(|| anyhow::anyhow!("Target stack has no ID"))?; + + // Get the stack details to find the head commit + let target_stack_details = crate::log::stack_details(&ctx, target_stack_id)?; + if target_stack_details.branch_details.is_empty() { + return Err(anyhow::anyhow!("Target stack has no branch details")); + } + + // Find the target branch in the stack details + let target_branch_details = target_stack_details + .branch_details + .iter() + .find(|b| b.name == target_branch_name) + .ok_or_else(|| { + anyhow::anyhow!( + "Target branch '{}' not found in stack details", + target_branch_name + ) + })?; + + // Get the head commit of the target branch + let target_head_oid = if !target_branch_details.commits.is_empty() { + // Use the last local commit + target_branch_details.commits.last().unwrap().id + } else if !target_branch_details.upstream_commits.is_empty() { + // If no local commits, use the last upstream commit + target_branch_details.upstream_commits.last().unwrap().id + } else { + return Err(anyhow::anyhow!( + "Target branch '{}' has no commits", + target_branch_name + )); + }; + + // Create a new virtual branch + let mut guard = project.exclusive_worktree_access(); + let create_request = BranchCreateRequest { + name: Some(branch_name.to_string()), + ownership: None, + order: None, + selected_for_changes: None, + }; + + let new_stack_id = + create_virtual_branch(&ctx, &create_request, guard.write_permission())?; + + // Now set up the new branch to start from the target branch's head + let vb_state = VirtualBranchesHandle::new(ctx.project().gb_dir()); + let mut new_stack = vb_state.get_stack(new_stack_id.id)?; + + // Set the head of the new stack to be the target branch's head + // This creates the stacking relationship + let gix_repo = ctx.repo().to_gix()?; + new_stack.set_stack_head(&vb_state, &gix_repo, target_head_oid.to_git2(), None)?; + vb_state.set_stack(new_stack)?; + + println!( + "{} Stacked branch '{}' created successfully!", + "✓".green().bold(), + branch_name.green().bold() + ); + } + None => { + // Create new empty virtual branch + println!( + "Creating new virtual branch '{}'", + branch_name.green().bold() + ); + + let mut guard = project.exclusive_worktree_access(); + let create_request = BranchCreateRequest { + name: Some(branch_name.to_string()), + ownership: None, + order: None, + selected_for_changes: None, + }; + + create_virtual_branch(&ctx, &create_request, guard.write_permission())?; + + println!( + "{} Virtual branch '{}' created successfully!", + "✓".green().bold(), + branch_name.green().bold() + ); + } + } + + Ok(()) +} + +pub(crate) fn unapply_branch(repo_path: &Path, _json: bool, branch_id: &str) -> anyhow::Result<()> { + let project = Project::from_path(repo_path)?; + let mut ctx = CommandContext::open(&project, AppSettings::load_from_default_path_creating()?)?; + + // Try to resolve the branch ID + let cli_ids = CliId::from_str(&mut ctx, branch_id)?; + + if cli_ids.is_empty() { + return Err(anyhow::anyhow!( + "Branch '{}' not found. Try using a branch CLI ID or full branch name.", + branch_id + )); + } + + if cli_ids.len() > 1 { + let matches: Vec = cli_ids + .iter() + .map(|id| match id { + CliId::Branch { name } => format!("{} (branch '{}')", id.to_string(), name), + _ => format!("{} ({})", id.to_string(), id.kind()), + }) + .collect(); + return Err(anyhow::anyhow!( + "Branch '{}' is ambiguous. Matches: {}. Try using more characters or the full branch name.", + branch_id, + matches.join(", ") + )); + } + + let cli_id = &cli_ids[0]; + let stack_id = match cli_id { + CliId::Branch { .. } => { + // Find the stack ID for this branch + let stacks = crate::log::stacks(&ctx)?; + let stack = stacks.iter().find(|s| { + s.heads.iter().any(|head| { + if let CliId::Branch { name } = cli_id { + head.name.to_string() == *name + } else { + false + } + }) + }); + + match stack { + Some(s) => s.id.ok_or_else(|| anyhow::anyhow!("Stack has no ID"))?, + None => return Err(anyhow::anyhow!("No stack found for branch '{}'", branch_id)), + } + } + _ => { + return Err(anyhow::anyhow!( + "ID '{}' does not refer to a branch (it's {})", + branch_id, + cli_id.kind() + )); + } + }; + + let branch_name = match cli_id { + CliId::Branch { name } => name, + _ => unreachable!(), + }; + + println!( + "Unapplying branch '{}' ({})", + branch_name.yellow().bold(), + branch_id.blue().underline() + ); + + unapply_stack(&ctx, stack_id, Vec::new())?; + + println!( + "{} Branch '{}' unapplied successfully!", + "✓".green().bold(), + branch_name.yellow().bold() + ); + + Ok(()) +} diff --git a/crates/but/src/commit/mod.rs b/crates/but/src/commit/mod.rs new file mode 100644 index 0000000000..8a858567d5 --- /dev/null +++ b/crates/but/src/commit/mod.rs @@ -0,0 +1,364 @@ +use crate::status::assignment::FileAssignment; +use bstr::{BString, ByteSlice}; +use but_core::ui::TreeChange; +use but_hunk_assignment::HunkAssignment; +use but_settings::AppSettings; +use but_workspace::DiffSpec; +use gitbutler_command_context::CommandContext; +use gitbutler_oplog::{ + OplogExt, + entry::{OperationKind, SnapshotDetails}, +}; +use gitbutler_project::Project; +use std::collections::BTreeMap; +use std::io::{self, Write}; +use std::path::Path; + +pub(crate) fn commit( + repo_path: &Path, + _json: bool, + message: Option<&str>, + branch_hint: Option<&str>, + only: bool, +) -> anyhow::Result<()> { + let project = Project::from_path(repo_path)?; + let mut ctx = CommandContext::open(&project, AppSettings::load_from_default_path_creating()?)?; + + // Get all stacks + let stack_entries = crate::log::stacks(&ctx)?; + let stacks: Vec<(but_workspace::StackId, but_workspace::ui::StackDetails)> = stack_entries + .iter() + .filter_map(|s| { + s.id.map(|id| { + crate::log::stack_details(&ctx, id) + .ok() + .map(|details| (id, details)) + }) + .flatten() + }) + .collect(); + + // Determine which stack to commit to + let target_stack_id = if stacks.is_empty() { + anyhow::bail!("No stacks found. Create a stack first with 'but branch new '."); + } else if stacks.len() == 1 { + // Only one stack, use it + stacks[0].0 + } else { + // Multiple stacks - need to select one + select_stack(&mut ctx, &stacks, branch_hint)? + }; + + // Get changes and assignments + let changes = + but_core::diff::ui::worktree_changes_by_worktree_dir(project.path.clone())?.changes; + let (assignments, _) = but_hunk_assignment::assignments_with_fallback( + &mut ctx, + false, + Some(changes.clone()), + None, + )?; + + // Group assignments by file + let mut by_file: BTreeMap> = BTreeMap::new(); + for assignment in &assignments { + by_file + .entry(assignment.path_bytes.clone()) + .or_default() + .push(assignment.clone()); + } + + let mut assignments_by_file: BTreeMap = BTreeMap::new(); + for (path, assignments) in &by_file { + assignments_by_file.insert( + path.clone(), + FileAssignment::from_assignments(path, assignments), + ); + } + + // Get files to commit: unassigned files + files assigned to target stack + let mut files_to_commit = Vec::new(); + + if !only { + // Add unassigned files (unless --only flag is used) + let unassigned = + crate::status::assignment::filter_by_stack_id(assignments_by_file.values(), &None); + files_to_commit.extend(unassigned); + } + + // Add files assigned to target stack + let stack_assigned = crate::status::assignment::filter_by_stack_id( + assignments_by_file.values(), + &Some(target_stack_id), + ); + files_to_commit.extend(stack_assigned); + + if files_to_commit.is_empty() { + println!("No changes to commit."); + return Ok(()); + } + + // Get commit message + let commit_message = if let Some(msg) = message { + msg.to_string() + } else { + get_commit_message_from_editor(&files_to_commit, &changes)? + }; + + if commit_message.trim().is_empty() { + anyhow::bail!("Aborting commit due to empty commit message."); + } + + // Find the target stack and determine the target branch + let target_stack = &stacks + .iter() + .find(|(id, _)| *id == target_stack_id) + .unwrap() + .1; + + // If a branch hint was provided, find that specific branch; otherwise use first branch + let target_branch = if let Some(hint) = branch_hint { + // First try exact name match + target_stack + .branch_details + .iter() + .find(|branch| branch.name.to_string() == hint) + .or_else(|| { + // If no exact match, try to parse as CLI ID and match + if let Ok(cli_ids) = crate::id::CliId::from_str(&mut ctx, hint) { + for cli_id in cli_ids { + if let crate::id::CliId::Branch { name } = cli_id { + if let Some(branch) = target_stack + .branch_details + .iter() + .find(|b| b.name.to_string() == name) + { + return Some(branch); + } + } + } + } + None + }) + .ok_or_else(|| anyhow::anyhow!("Branch '{}' not found in target stack", hint))? + } else { + // No branch hint, use first branch (HEAD of stack) + target_stack + .branch_details + .first() + .ok_or_else(|| anyhow::anyhow!("No branches found in target stack"))? + }; + + // Convert files to DiffSpec + let diff_specs: Vec = files_to_commit + .iter() + .map(|fa| { + // Collect hunk headers from all assignments for this file + let hunk_headers: Vec = fa + .assignments + .iter() + .filter_map(|assignment| assignment.hunk_header.clone()) + .collect(); + + DiffSpec { + previous_path: None, + path: fa.path.clone(), + hunk_headers, + } + }) + .collect(); + + // Get the HEAD commit of the target branch to use as parent (preserves stacking) + let parent_commit_id = target_branch.tip; + + // Create a snapshot before committing + let mut guard = project.exclusive_worktree_access(); + let _snapshot = ctx + .create_snapshot( + SnapshotDetails::new(OperationKind::CreateCommit), + guard.write_permission(), + ) + .ok(); // Ignore errors for snapshot creation + + // Commit using the simpler commit engine with explicit parent to preserve stacking + let outcome = but_workspace::commit_engine::create_commit_simple( + &ctx, + target_stack_id, + Some(parent_commit_id), // Use the branch HEAD as parent to preserve stacking + diff_specs, + commit_message, + target_branch.name.to_string(), + guard.write_permission(), + )?; + + let commit_short = match outcome.new_commit { + Some(id) => id.to_string()[..7].to_string(), + None => "unknown".to_string(), + }; + println!( + "Created commit {} on branch {}", + commit_short, target_branch.name + ); + + Ok(()) +} + +fn select_stack( + ctx: &mut CommandContext, + stacks: &[(but_workspace::StackId, but_workspace::ui::StackDetails)], + branch_hint: Option<&str>, +) -> anyhow::Result { + // If a branch hint is provided, try to find it + if let Some(hint) = branch_hint { + // First, try to find by exact branch name match + for (stack_id, stack_details) in stacks { + for branch in &stack_details.branch_details { + if branch.name.to_string() == hint { + return Ok(*stack_id); + } + } + } + + // If no exact match, try to parse as CLI ID + match crate::id::CliId::from_str(ctx, hint) { + Ok(cli_ids) => { + // Filter for branch CLI IDs and find corresponding stack + for cli_id in cli_ids { + if let crate::id::CliId::Branch { name } = cli_id { + for (stack_id, stack_details) in stacks { + for branch in &stack_details.branch_details { + if branch.name.to_string() == name { + return Ok(*stack_id); + } + } + } + } + } + } + Err(_) => { + // Ignore CLI ID parsing errors and continue with other methods + } + } + + anyhow::bail!("Branch '{}' not found", hint); + } + + // No hint provided, show options and prompt + println!("Multiple stacks found. Choose one to commit to:"); + for (i, (stack_id, stack_details)) in stacks.iter().enumerate() { + let branch_names: Vec = stack_details + .branch_details + .iter() + .map(|b| b.name.to_string()) + .collect(); + println!(" {}. {} [{}]", i + 1, stack_id, branch_names.join(", ")); + } + + print!("Enter selection (1-{}): ", stacks.len()); + io::stdout().flush()?; + + let mut input = String::new(); + io::stdin().read_line(&mut input)?; + + let selection: usize = input + .trim() + .parse() + .map_err(|_| anyhow::anyhow!("Invalid selection"))?; + + if selection < 1 || selection > stacks.len() { + anyhow::bail!("Selection out of range"); + } + + Ok(stacks[selection - 1].0) +} + +fn get_commit_message_from_editor( + files_to_commit: &[FileAssignment], + changes: &[TreeChange], +) -> anyhow::Result { + // Get editor command + let editor = get_editor_command()?; + + // Create temporary file with template + let temp_dir = std::env::temp_dir(); + let temp_file = temp_dir.join(format!("but_commit_msg_{}", std::process::id())); + + // Generate commit message template + let mut template = String::new(); + template.push_str("\n# Please enter the commit message for your changes. Lines starting\n"); + template.push_str("# with '#' will be ignored, and an empty message aborts the commit.\n"); + template.push_str("#\n"); + template.push_str("# Changes to be committed:\n"); + + for fa in files_to_commit { + let status_char = get_status_char(&fa.path, changes); + template.push_str(&format!("#\t{} {}\n", status_char, fa.path.to_str_lossy())); + } + template.push_str("#\n"); + + std::fs::write(&temp_file, template)?; + + // Launch editor + let status = std::process::Command::new(&editor) + .arg(&temp_file) + .status()?; + + if !status.success() { + anyhow::bail!("Editor exited with non-zero status"); + } + + // Read the result and strip comments + let content = std::fs::read_to_string(&temp_file)?; + std::fs::remove_file(&temp_file).ok(); // Best effort cleanup + + let message = content + .lines() + .filter(|line| !line.starts_with('#')) + .collect::>() + .join("\n") + .trim() + .to_string(); + + Ok(message) +} + +fn get_editor_command() -> anyhow::Result { + // Try $EDITOR first + if let Ok(editor) = std::env::var("EDITOR") { + return Ok(editor); + } + + // Try git config core.editor + if let Ok(output) = std::process::Command::new("git") + .args(&["config", "--get", "core.editor"]) + .output() + { + if output.status.success() { + let editor = String::from_utf8_lossy(&output.stdout).trim().to_string(); + if !editor.is_empty() { + return Ok(editor); + } + } + } + + // Fallback to platform defaults + #[cfg(windows)] + return Ok("notepad".to_string()); + + #[cfg(not(windows))] + return Ok("vi".to_string()); +} + +fn get_status_char(path: &BString, changes: &[TreeChange]) -> &'static str { + for change in changes { + if change.path_bytes == *path { + return match change.status { + but_core::ui::TreeStatus::Addition { .. } => "new file:", + but_core::ui::TreeStatus::Modification { .. } => "modified:", + but_core::ui::TreeStatus::Deletion { .. } => "deleted:", + but_core::ui::TreeStatus::Rename { .. } => "renamed:", + }; + } + } + "modified:" // fallback +} diff --git a/crates/but/src/config.rs b/crates/but/src/config.rs new file mode 100644 index 0000000000..4f66cea50b --- /dev/null +++ b/crates/but/src/config.rs @@ -0,0 +1,297 @@ +use anyhow::{Context, Result}; +use but_settings::AppSettings; +use gitbutler_repo::Config; +use gitbutler_secret::secret; +use gitbutler_user; +use serde::{Deserialize, Serialize}; +use std::path::Path; + +#[derive(Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ConfigInfo { + pub repository_path: String, + pub user_info: UserInfo, + pub gitbutler_info: GitButlerInfo, + pub ai_tooling: AiToolingInfo, +} + +#[derive(Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct UserInfo { + pub name: Option, + pub email: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct GitButlerInfo { + pub username: Option, + pub status: String, +} + +#[derive(Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AiToolingInfo { + pub openai: AiProviderInfo, + pub anthropic: AiProviderInfo, + pub ollama: AiProviderInfo, +} + +#[derive(Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AiProviderInfo { + pub configured: bool, + pub model: Option, +} + +pub fn handle( + current_dir: &Path, + app_settings: &AppSettings, + json: bool, + key: Option<&str>, + value: Option<&str>, +) -> Result<()> { + match (key, value) { + // Set configuration value + (Some(key), Some(value)) => { + set_config_value(current_dir, key, value)?; + if !json { + println!("Set {} = {}", key, value); + } + Ok(()) + } + // Get specific configuration value + (Some(key), None) => { + let config_value = get_config_value(current_dir, key)?; + if json { + let result = serde_json::json!({ + "key": key, + "value": config_value + }); + println!("{}", serde_json::to_string_pretty(&result)?); + } else { + match config_value { + Some(val) => println!("{}", val), + None => println!("{} is not set", key), + } + } + Ok(()) + } + // Show all configuration (existing behavior) + (None, None) => show(current_dir, app_settings, json), + // Invalid: value without key + (None, Some(_)) => Err(anyhow::anyhow!( + "Cannot set a value without specifying a key" + )), + } +} + +pub fn show(current_dir: &Path, app_settings: &AppSettings, json: bool) -> Result<()> { + let config_info = gather_config_info(current_dir, app_settings)?; + + if json { + println!("{}", serde_json::to_string_pretty(&config_info)?); + } else { + print_formatted_config(&config_info); + } + + Ok(()) +} + +fn gather_config_info(current_dir: &Path, _app_settings: &AppSettings) -> Result { + let user_info = get_git_user_info(current_dir)?; + let gitbutler_info = get_gitbutler_info()?; + let ai_tooling = get_ai_tooling_info()?; + + Ok(ConfigInfo { + repository_path: current_dir.display().to_string(), + user_info, + gitbutler_info, + ai_tooling, + }) +} + +fn get_git_user_info(current_dir: &Path) -> Result { + let git_repo = + git2::Repository::discover(current_dir).context("Failed to find Git repository")?; + let config = Config::from(&git_repo); + + let name = config.user_name().unwrap_or(None); + let email = config.user_email().unwrap_or(None); + + Ok(UserInfo { name, email }) +} + +fn get_gitbutler_info() -> Result { + let (username, status) = match gitbutler_user::get_user() { + Ok(Some(user)) => { + let username = user.login.clone(); + let status = if user.access_token().is_ok() { + "Connected ✓".to_string() + } else { + "Not connected ✗".to_string() + }; + (username, status) + } + _ => (None, "Not configured ✗".to_string()), + }; + + Ok(GitButlerInfo { username, status }) +} + +fn get_ai_tooling_info() -> Result { + let openai = check_openai_config(); + let anthropic = check_anthropic_config(); + let ollama = check_ollama_config(); + + Ok(AiToolingInfo { + openai, + anthropic, + ollama, + }) +} + +fn check_openai_config() -> AiProviderInfo { + let has_env_key = std::env::var("OPENAI_API_KEY").is_ok(); + let has_stored_key = secret::retrieve("openai_api_key", secret::Namespace::BuildKind).is_ok(); + let has_gb_token = + secret::retrieve("gitbutler_access_token", secret::Namespace::BuildKind).is_ok(); + + let configured = has_env_key || has_stored_key || has_gb_token; + let model = if configured { + Some("gpt-4".to_string()) + } else { + None + }; + + AiProviderInfo { configured, model } +} + +fn check_anthropic_config() -> AiProviderInfo { + let has_key = secret::retrieve("anthropic_api_key", secret::Namespace::BuildKind).is_ok(); + + let model = if has_key { + Some("claude-3-5-sonnet".to_string()) + } else { + None + }; + + AiProviderInfo { + configured: has_key, + model, + } +} + +fn check_ollama_config() -> AiProviderInfo { + let configured = std::process::Command::new("curl") + .arg("-s") + .arg("--connect-timeout") + .arg("2") + .arg("http://localhost:11434/api/tags") + .output() + .map(|output| output.status.success()) + .unwrap_or(false); + + let model = if configured { + Some("llama2:7b".to_string()) + } else { + None + }; + + AiProviderInfo { configured, model } +} + +fn print_formatted_config(config: &ConfigInfo) { + println!("Configuration for {}", config.repository_path); + println!("=================================================="); + println!(); + + println!("👤 User Information:"); + println!( + " Name (user.name): {}", + config.user_info.name.as_deref().unwrap_or("Not configured") + ); + println!( + " Email (user.email): {}", + config + .user_info + .email + .as_deref() + .unwrap_or("Not configured") + ); + println!(); + + println!("🚀 GitButler:"); + println!( + " Username (user.login): {}", + config + .gitbutler_info + .username + .as_deref() + .unwrap_or("Not configured") + ); + println!(" Status: {}", config.gitbutler_info.status); + println!(); + + println!("🤖 AI Tooling:"); + print_ai_provider("OpenAI", &config.ai_tooling.openai); + print_ai_provider("Anthropic", &config.ai_tooling.anthropic); + print_ai_provider("Ollama", &config.ai_tooling.ollama); +} + +fn print_ai_provider(name: &str, provider: &AiProviderInfo) { + let status = if provider.configured { "✓" } else { "✗" }; + let model_info = match &provider.model { + Some(model) => format!(" ({})", model), + None => String::new(), + }; + + println!( + " {:10} {}{}{}", + format!("{}:", name), + if provider.configured { + "Configured" + } else { + "Not configured" + }, + if provider.configured { " " } else { " " }, + if provider.configured { status } else { status } + ); + if !model_info.is_empty() && provider.configured { + println!(" {}", model_info.trim_start()); + } +} + +fn set_config_value(current_dir: &Path, key: &str, value: &str) -> Result<()> { + let git_repo = + git2::Repository::discover(current_dir).context("Failed to find Git repository")?; + let config = Config::from(&git_repo); + + config + .set_local(key, value) + .with_context(|| format!("Failed to set {} = {}", key, value)) +} + +fn get_config_value(current_dir: &Path, key: &str) -> Result> { + let git_repo = + git2::Repository::discover(current_dir).context("Failed to find Git repository")?; + let config = Config::from(&git_repo); + + // For getting values, use the same logic as the existing code + // which checks the full git config hierarchy (local, global, system) + match key { + "user.name" => config.user_name(), + "user.email" => config.user_email(), + _ => { + // For other keys, try to get the value from git config + let git_config = git_repo.config()?; + match git_config.get_string(key) { + Ok(value) => Ok(Some(value)), + Err(err) => match err.code() { + git2::ErrorCode::NotFound => Ok(None), + _ => Err(err.into()), + }, + } + } + } +} diff --git a/crates/but/src/describe/mod.rs b/crates/but/src/describe/mod.rs new file mode 100644 index 0000000000..c11a8bce9c --- /dev/null +++ b/crates/but/src/describe/mod.rs @@ -0,0 +1,297 @@ +use crate::id::CliId; +use anyhow::Result; +use but_settings::AppSettings; +use gitbutler_command_context::CommandContext; +use gitbutler_oplog::{ + OplogExt, + entry::{OperationKind, SnapshotDetails}, +}; +use gitbutler_oxidize::ObjectIdExt; +use gitbutler_project::Project; +use std::path::Path; + +pub(crate) fn edit_commit_message( + repo_path: &Path, + _json: bool, + commit_target: &str, +) -> Result<()> { + let project = Project::from_path(repo_path)?; + let mut ctx = CommandContext::open(&project, AppSettings::load_from_default_path_creating()?)?; + + // Resolve the commit ID + let cli_ids = CliId::from_str(&mut ctx, commit_target)?; + + if cli_ids.is_empty() { + anyhow::bail!("Commit '{}' not found", commit_target); + } + + if cli_ids.len() > 1 { + anyhow::bail!( + "Commit '{}' is ambiguous. Found {} matches", + commit_target, + cli_ids.len() + ); + } + + let cli_id = &cli_ids[0]; + + match cli_id { + CliId::Commit { oid } => { + edit_commit_message_by_id(&ctx, &project, *oid)?; + } + _ => { + anyhow::bail!("Target must be a commit ID, not {}", cli_id.kind()); + } + } + + Ok(()) +} + +fn edit_commit_message_by_id( + ctx: &CommandContext, + project: &Project, + commit_oid: gix::ObjectId, +) -> Result<()> { + // Find which stack this commit belongs to + let stacks = crate::log::stacks(ctx)?; + let mut found_commit_message = None; + let mut stack_id = None; + + for stack_entry in &stacks { + if let Some(sid) = stack_entry.id { + let stack_details = crate::log::stack_details(ctx, sid)?; + + // Check if this commit exists in any branch of this stack + for branch_details in &stack_details.branch_details { + // Check local commits + for commit in &branch_details.commits { + if commit.id == commit_oid { + found_commit_message = Some(commit.message.clone()); + stack_id = Some(sid); + break; + } + } + + // Also check upstream commits + if found_commit_message.is_none() { + for commit in &branch_details.upstream_commits { + if commit.id == commit_oid { + found_commit_message = Some(commit.message.clone()); + stack_id = Some(sid); + break; + } + } + } + + if found_commit_message.is_some() { + break; + } + } + if found_commit_message.is_some() { + break; + } + } + } + + let commit_message = found_commit_message + .ok_or_else(|| anyhow::anyhow!("Commit {} not found in any stack", commit_oid))?; + + let stack_id = stack_id + .ok_or_else(|| anyhow::anyhow!("Could not find stack for commit {}", commit_oid))?; + + // Get the files changed in this commit + let changed_files = get_commit_changed_files(&ctx.repo(), commit_oid)?; + + // Get current commit message + let current_message = commit_message.to_string(); + + // Open editor with current message and file list + let new_message = get_commit_message_from_editor(¤t_message, &changed_files)?; + + if new_message.trim() == current_message.trim() { + println!("No changes to commit message."); + return Ok(()); + } + + // Create a snapshot before making changes + let mut guard = project.exclusive_worktree_access(); + let _snapshot = ctx + .create_snapshot( + SnapshotDetails::new(OperationKind::AmendCommit), + guard.write_permission(), + ) + .ok(); // Ignore errors for snapshot creation + + // Amend the commit with the new message + let gix_repo = crate::mcp_internal::project::project_repo(&project.path)?; + let outcome = but_workspace::commit_engine::create_commit_and_update_refs_with_project( + &gix_repo, + project, + Some(stack_id), + but_workspace::commit_engine::Destination::AmendCommit { + commit_id: commit_oid, + new_message: Some(new_message.clone()), + }, + None, // move_source + vec![], // No file changes, just message + 0, // context_lines + guard.write_permission(), + )?; + + if let Some(new_commit_id) = outcome.new_commit { + println!( + "Updated commit message for {} (now {})", + &commit_oid.to_string()[..7], + &new_commit_id.to_string()[..7] + ); + } else { + println!( + "Updated commit message for {}", + &commit_oid.to_string()[..7] + ); + } + + Ok(()) +} + +fn get_commit_changed_files( + repo: &git2::Repository, + commit_oid: gix::ObjectId, +) -> Result> { + let git2_oid = commit_oid.to_git2(); + let commit = repo.find_commit(git2_oid)?; + + if commit.parent_count() == 0 { + // Initial commit - show all files as new + let tree = commit.tree()?; + let mut files = Vec::new(); + tree.walk(git2::TreeWalkMode::PreOrder, |root, entry| { + if entry.kind() == Some(git2::ObjectType::Blob) { + let full_path = if root.is_empty() { + entry.name().unwrap_or("").to_string() + } else { + format!("{}{}", root, entry.name().unwrap_or("")) + }; + files.push(format!("new file: {}", full_path)); + } + git2::TreeWalkResult::Ok + })?; + return Ok(files); + } + + // Get parent commit and compare trees + let parent = commit.parent(0)?; + let parent_tree = parent.tree()?; + let commit_tree = commit.tree()?; + + // Use git2 diff to get the changes with status information + let mut diff_opts = git2::DiffOptions::new(); + diff_opts.show_binary(true).ignore_submodules(true); + let diff = + repo.diff_tree_to_tree(Some(&parent_tree), Some(&commit_tree), Some(&mut diff_opts))?; + + let mut files = Vec::new(); + diff.print(git2::DiffFormat::NameStatus, |delta, _hunk, _line| { + let status = match delta.status() { + git2::Delta::Added => "new file:", + git2::Delta::Modified | git2::Delta::Renamed | git2::Delta::Copied => "modified:", + git2::Delta::Deleted => "deleted:", + _ => "modified:", + }; + let file_path = delta.new_file().path().unwrap_or_else(|| { + delta + .old_file() + .path() + .expect("failed to get file name from diff") + }); + files.push(format!("{} {}", status, file_path.display())); + true // Continue iteration + })?; + + files.sort(); + Ok(files) +} + +fn get_commit_message_from_editor( + current_message: &str, + changed_files: &[String], +) -> Result { + // Get editor command + let editor = get_editor_command()?; + + // Create temporary file with current message and file list + let temp_dir = std::env::temp_dir(); + let temp_file = temp_dir.join(format!("but_commit_msg_{}", std::process::id())); + + // Generate commit message template with current message + let mut template = String::new(); + template.push_str(¤t_message); + if !current_message.is_empty() && !current_message.ends_with('\n') { + template.push('\n'); + } + template.push_str("\n# Please enter the commit message for your changes. Lines starting\n"); + template.push_str("# with '#' will be ignored, and an empty message aborts the commit.\n"); + template.push_str("#\n"); + template.push_str("# Changes in this commit:\n"); + + for file in changed_files { + template.push_str(&format!("#\t{}\n", file)); + } + template.push_str("#\n"); + + std::fs::write(&temp_file, template)?; + + // Launch editor + let status = std::process::Command::new(&editor) + .arg(&temp_file) + .status()?; + + if !status.success() { + anyhow::bail!("Editor exited with non-zero status"); + } + + // Read the result and strip comments + let content = std::fs::read_to_string(&temp_file)?; + std::fs::remove_file(&temp_file).ok(); // Best effort cleanup + + let message = content + .lines() + .filter(|line| !line.starts_with('#')) + .collect::>() + .join("\n") + .trim() + .to_string(); + + if message.is_empty() { + anyhow::bail!("Aborting due to empty commit message"); + } + + Ok(message) +} + +fn get_editor_command() -> Result { + // Try $EDITOR first + if let Ok(editor) = std::env::var("EDITOR") { + return Ok(editor); + } + + // Try git config core.editor + if let Ok(output) = std::process::Command::new("git") + .args(&["config", "--get", "core.editor"]) + .output() + { + if output.status.success() { + let editor = String::from_utf8_lossy(&output.stdout).trim().to_string(); + if !editor.is_empty() { + return Ok(editor); + } + } + } + + // Fallback to platform defaults + #[cfg(windows)] + return Ok("notepad".to_string()); + + #[cfg(not(windows))] + return Ok("vi".to_string()); +} diff --git a/crates/but/src/id/mod.rs b/crates/but/src/id/mod.rs index 9f16f970aa..023080edec 100644 --- a/crates/but/src/id/mod.rs +++ b/crates/but/src/id/mod.rs @@ -10,6 +10,10 @@ pub enum CliId { path: String, assignment: Option, }, + CommittedFile { + path: String, + commit_oid: gix::ObjectId, + }, Branch { name: String, }, @@ -23,6 +27,7 @@ impl CliId { pub fn kind(&self) -> &'static str { match self { CliId::UncommittedFile { .. } => "an uncommitted file", + CliId::CommittedFile { .. } => "a committed file", CliId::Branch { .. } => "a branch", CliId::Commit { .. } => "a commit", CliId::Unassigned => "the unassigned area", @@ -49,37 +54,141 @@ impl CliId { } } + pub fn committed_file(path: &str, commit_oid: gix::ObjectId) -> Self { + CliId::CommittedFile { + path: path.to_string(), + commit_oid, + } + } + + fn find_branches_by_name(ctx: &CommandContext, name: &str) -> anyhow::Result> { + let stacks = crate::log::stacks(ctx)?; + let mut matches = Vec::new(); + + for stack in stacks { + for head in &stack.heads { + let branch_name = head.name.to_string(); + // Exact match or partial match + if branch_name == name || branch_name.contains(name) { + matches.push(CliId::branch(&branch_name)); + } + } + } + + Ok(matches) + } + + fn find_commits_by_sha(ctx: &CommandContext, sha_prefix: &str) -> anyhow::Result> { + let mut matches = Vec::new(); + + // Only try SHA matching if the input looks like a hex string + if sha_prefix.chars().all(|c| c.is_ascii_hexdigit()) && sha_prefix.len() >= 4 { + let all_commits = crate::log::all_commits(ctx)?; + for commit_id in all_commits { + if let CliId::Commit { oid } = &commit_id { + let sha_string = oid.to_string(); + if sha_string.starts_with(sha_prefix) { + matches.push(commit_id); + } + } + } + } + + Ok(matches) + } + pub fn matches(&self, s: &str) -> bool { s == self.to_string() } + pub fn matches_prefix(&self, s: &str) -> bool { + match self { + CliId::Commit { oid } => { + let oid_hash = hash(&oid.to_string()); + oid_hash.starts_with(s) + } + _ => self.to_string().starts_with(s), + } + } + pub fn from_str(ctx: &mut CommandContext, s: &str) -> anyhow::Result> { if s.len() < 2 { - return Err(anyhow::anyhow!("Id needs to be 3 characters long: {}", s)); + return Err(anyhow::anyhow!( + "Id needs to be at least 2 characters long: {}", + s + )); } - let s = &s[..2]; - let mut everything = Vec::new(); - crate::status::all_files(ctx)? - .into_iter() - .filter(|id| id.matches(s)) - .for_each(|id| everything.push(id)); - crate::status::all_branches(ctx)? - .into_iter() - .filter(|id| id.matches(s)) - .for_each(|id| everything.push(id)); - crate::log::all_commits(ctx)? - .into_iter() - .filter(|id| id.matches(s)) - .for_each(|id| everything.push(id)); - everything.push(CliId::unassigned()); let mut matches = Vec::new(); - for id in everything { - if id.matches(s) { - matches.push(id); + + // First, try exact branch name match + if let Ok(branch_matches) = Self::find_branches_by_name(ctx, s) { + matches.extend(branch_matches); + } + + // Then try partial SHA matches (for commits) + if let Ok(commit_matches) = Self::find_commits_by_sha(ctx, s) { + matches.extend(commit_matches); + } + + // Then try CliId matching (both prefix and exact) + if s.len() > 2 { + // For longer strings, try prefix matching on CliIds + let mut cli_matches = Vec::new(); + crate::status::all_files(ctx)? + .into_iter() + .filter(|id| id.matches_prefix(s)) + .for_each(|id| cli_matches.push(id)); + crate::status::all_committed_files(ctx)? + .into_iter() + .filter(|id| id.matches_prefix(s)) + .for_each(|id| cli_matches.push(id)); + crate::status::all_branches(ctx)? + .into_iter() + .filter(|id| id.matches_prefix(s)) + .for_each(|id| cli_matches.push(id)); + crate::log::all_commits(ctx)? + .into_iter() + .filter(|id| id.matches_prefix(s)) + .for_each(|id| cli_matches.push(id)); + if CliId::unassigned().matches_prefix(s) { + cli_matches.push(CliId::unassigned()); } + matches.extend(cli_matches); + } else { + // For 2-character strings, try exact CliId matching + let mut cli_matches = Vec::new(); + crate::status::all_files(ctx)? + .into_iter() + .filter(|id| id.matches(s)) + .for_each(|id| cli_matches.push(id)); + crate::status::all_committed_files(ctx)? + .into_iter() + .filter(|id| id.matches(s)) + .for_each(|id| cli_matches.push(id)); + crate::status::all_branches(ctx)? + .into_iter() + .filter(|id| id.matches(s)) + .for_each(|id| cli_matches.push(id)); + crate::log::all_commits(ctx)? + .into_iter() + .filter(|id| id.matches(s)) + .for_each(|id| cli_matches.push(id)); + if CliId::unassigned().matches(s) { + cli_matches.push(CliId::unassigned()); + } + matches.extend(cli_matches); } - Ok(matches) + + // Remove duplicates while preserving order + let mut unique_matches = Vec::new(); + for m in matches { + if !unique_matches.contains(&m) { + unique_matches.push(m); + } + } + + Ok(unique_matches) } } @@ -94,6 +203,10 @@ impl Display for CliId { write!(f, "{}", hash(path)) } } + CliId::CommittedFile { path, commit_oid } => { + let value = hash(&format!("{}{}", commit_oid, path)); + write!(f, "{}", value) + } CliId::Branch { name } => { write!(f, "{}", hash(name)) } @@ -101,8 +214,8 @@ impl Display for CliId { write!(f, "00") } CliId::Commit { oid } => { - let oid = oid.to_string(); - write!(f, "{}", &oid[..2]) + let oid_str = oid.to_string(); + write!(f, "{}", hash(&oid_str)) } } } @@ -113,12 +226,15 @@ pub(crate) fn hash(input: &str) -> String { for byte in input.bytes() { hash = hash.wrapping_mul(31).wrapping_add(byte as u64); } - // Convert to base 36 (0-9, a-z) - let chars = "0123456789abcdefghijklmnopqrstuvwxyz"; - let mut result = String::new(); - for _ in 0..2 { - result.push(chars.chars().nth((hash % 36) as usize).unwrap()); - hash /= 36; - } - result + + // First character: g-z (20 options) + let first_chars = "ghijklmnopqrstuvwxyz"; + let first_char = first_chars.chars().nth((hash % 20) as usize).unwrap(); + hash /= 20; + + // Second character: 0-9,a-z (36 options) + let second_chars = "0123456789abcdefghijklmnopqrstuvwxyz"; + let second_char = second_chars.chars().nth((hash % 36) as usize).unwrap(); + + format!("{}{}", first_char, second_char) } diff --git a/crates/but/src/log/mod.rs b/crates/but/src/log/mod.rs index 46c162f2a2..bd4cc6383b 100644 --- a/crates/but/src/log/mod.rs +++ b/crates/but/src/log/mod.rs @@ -11,17 +11,32 @@ use std::path::Path; use crate::id::CliId; -pub(crate) fn commit_graph(repo_path: &Path, _json: bool) -> anyhow::Result<()> { +pub(crate) fn commit_graph(repo_path: &Path, json: bool, short: 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::>(); + if json { + return output_json(stacks.iter().map(|(_, d)| d).cloned().collect()); + } + + if short { + return commit_graph_short(stacks.iter().map(|(_, d)| d).cloned().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 +60,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"))? @@ -73,16 +90,23 @@ pub(crate) fn commit_graph(repo_path: &Path, _json: bool) -> anyhow::Result<()> "{}{}┊ {}", "│ ".repeat(nesting), extra_space, - commit.message.to_string().lines().next().unwrap_or("") + format_commit_message(&commit.message.to_string()) ); let bend = if stacked { "├" } else { "╭" }; if j == branch.upstream_commits.len() - 1 { - println!("{}{}─╯", "│ ".repeat(nesting), bend); + println!("{}{}", "│ ".repeat(nesting), bend); } else { println!("{} ┊", "│ ".repeat(nesting)); } } 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,19 +122,20 @@ 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!( "{}│ {}", "│ ".repeat(nesting), - commit.message.to_string().lines().next().unwrap_or("") + format_commit_message(&commit.message.to_string()) ); if i == stacks.len() - 1 { if nesting == 0 { @@ -127,10 +152,10 @@ pub(crate) fn commit_graph(repo_path: &Path, _json: bool) -> anyhow::Result<()> if nesting > 0 { for _ in (0..nesting - 1).rev() { if nesting == 1 { - println!("└─╯"); + println!("│"); } else { let prefix = "│ ".repeat(nesting - 2); - println!("{prefix}├─╯"); + println!("{}├─╯", prefix); } nesting -= 1; } @@ -146,6 +171,85 @@ pub(crate) fn commit_graph(repo_path: &Path, _json: bool) -> anyhow::Result<()> Ok(()) } +fn commit_graph_short(stacks: Vec) -> anyhow::Result<()> { + let mut nesting = 0; + + for (_i, stack) in stacks.iter().enumerate() { + let mut second_consecutive = false; + + for branch in stack.branch_details.iter() { + let line = if second_consecutive { + if branch.upstream_commits.is_empty() { + '├' + } else { + '╭' + } + } else { + '╭' + }; + second_consecutive = branch.upstream_commits.is_empty(); + + let extra_space = if !branch.upstream_commits.is_empty() { + " " + } else { + "" + }; + + let id = CliId::branch(&branch.name.to_string()) + .to_string() + .underline() + .blue(); + + // Count commits + let upstream_count = branch.upstream_commits.len(); + let local_count = branch.commits.len(); + let total_count = upstream_count + local_count; + + let count_info = if total_count == 0 { + "no commits".dimmed() + } else if upstream_count > 0 && local_count > 0 { + format!( + "{} commits ({} upstream, {} local)", + total_count, upstream_count, local_count + ) + .cyan() + } else if upstream_count > 0 { + format!("{} upstream commits", upstream_count).yellow() + } else { + format!("{} local commits", local_count).green() + }; + + println!( + "{}{}{} [{}] {} - {}", + "│ ".repeat(nesting), + extra_space, + line, + branch.name.to_string().green().bold(), + id, + count_info + ); + } + nesting += 1; + } + + if nesting > 0 { + for _ in (0..nesting - 1).rev() { + if nesting == 1 { + println!("│"); + } else { + let prefix = "│ ".repeat(nesting - 2); + println!("{}│", prefix); + } + nesting -= 1; + } + } + + // Show the base commit (same as in detailed view) + println!("● (base)"); + + Ok(()) +} + pub(crate) fn all_commits(ctx: &CommandContext) -> anyhow::Result> { let stacks = stacks(ctx)? .iter() @@ -192,3 +296,18 @@ pub(crate) fn stack_details( but_workspace::stack_details(&ctx.project().gb_dir(), stack_id, ctx) } } + +fn output_json(stacks: Vec) -> anyhow::Result<()> { + let json_output = serde_json::to_string_pretty(&stacks)?; + println!("{}", json_output); + Ok(()) +} + +fn format_commit_message(message: &str) -> String { + let message_line = message.lines().next().unwrap_or(""); + if message_line.trim().is_empty() { + "(blank message)".to_string() + } else { + message_line.to_string() + } +} diff --git a/crates/but/src/main.rs b/crates/but/src/main.rs index 38b60fa381..094554fa0e 100644 --- a/crates/but/src/main.rs +++ b/crates/but/src/main.rs @@ -1,22 +1,39 @@ use anyhow::{Context, Result}; mod args; -use args::{Args, CommandName, Subcommands, actions, claude}; +use args::{Args, BranchSubcommands, CommandName, Subcommands, actions, claude}; use but_settings::AppSettings; use metrics::{Event, Metrics, Props, metrics_if_configured}; use but_claude::hooks::OutputAsJson; +mod branch; mod command; +mod commit; +mod config; +mod describe; mod id; mod log; +mod mark; mod mcp; mod mcp_internal; mod metrics; +mod new; +mod oplog; +mod restore; mod rub; mod status; +mod undo; #[tokio::main] async fn main() -> Result<()> { + // Check if help is requested with no subcommand + if std::env::args().len() == 1 + || std::env::args().any(|arg| arg == "--help" || arg == "-h") && std::env::args().len() == 2 + { + print_grouped_help(); + return Ok(()); + } + let args: Args = clap::Parser::parse(); let app_settings = AppSettings::load_from_default_path_creating()?; @@ -79,16 +96,80 @@ async fn main() -> Result<()> { but_claude::mcp::start(&args.current_dir).await } }, - Subcommands::Log => { - let result = log::commit_graph(&args.current_dir, args.json); + Subcommands::Log { short } => { + let result = log::commit_graph(&args.current_dir, args.json, *short); metrics_if_configured(app_settings, CommandName::Log, props(start, &result)).ok(); Ok(()) } - Subcommands::Status => { - let result = status::worktree(&args.current_dir, args.json); + Subcommands::Status { base, files } => { + let result = status::worktree(&args.current_dir, args.json, *base, *files); + metrics_if_configured(app_settings, CommandName::Status, props(start, &result)).ok(); + Ok(()) + } + Subcommands::StatusFiles { base } => { + let result = status::worktree(&args.current_dir, args.json, *base, true); metrics_if_configured(app_settings, CommandName::Status, props(start, &result)).ok(); Ok(()) } + Subcommands::Config { key, value } => { + let result = config::handle( + &args.current_dir, + &app_settings, + args.json, + key.as_deref(), + value.as_deref(), + ); + metrics_if_configured(app_settings, CommandName::Config, props(start, &result)).ok(); + result + } + Subcommands::Oplog { since } => { + let result = oplog::show_oplog(&args.current_dir, args.json, since.as_deref()); + metrics_if_configured(app_settings, CommandName::Oplog, props(start, &result)).ok(); + result + } + Subcommands::Undo => { + let result = undo::undo_last_operation(&args.current_dir, args.json); + metrics_if_configured(app_settings, CommandName::Undo, props(start, &result)).ok(); + result + } + Subcommands::Restore { oplog_sha } => { + let result = restore::restore_to_oplog(&args.current_dir, args.json, oplog_sha); + metrics_if_configured(app_settings, CommandName::Restore, props(start, &result)).ok(); + result + } + Subcommands::Commit { + message, + branch, + only, + } => { + let result = commit::commit( + &args.current_dir, + args.json, + message.as_deref(), + branch.as_deref(), + *only, + ); + metrics_if_configured(app_settings, CommandName::Commit, props(start, &result)).ok(); + result + } + Subcommands::New { target } => { + let result = new::insert_blank_commit(&args.current_dir, args.json, target); + metrics_if_configured(app_settings, CommandName::New, props(start, &result)).ok(); + result + } + Subcommands::Describe { commit } => { + let result = describe::edit_commit_message(&args.current_dir, args.json, commit); + metrics_if_configured(app_settings, CommandName::Describe, props(start, &result)).ok(); + result + } + Subcommands::Branch { cmd } => match cmd { + BranchSubcommands::New { branch_name, id } => { + branch::create_branch(&args.current_dir, args.json, branch_name, id.as_deref()) + } + BranchSubcommands::Unapply { branch_id } => { + branch::unapply_branch(&args.current_dir, args.json, branch_id) + } + }, Subcommands::Rub { source, target } => { let result = rub::handle(&args.current_dir, args.json, source, target) .context("Rubbed the wrong way."); @@ -98,6 +179,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(()) + } } } @@ -112,3 +202,65 @@ where props.insert("error", error); props } + +fn print_grouped_help() { + use clap::CommandFactory; + use std::collections::HashSet; + + let cmd = Args::command(); + let subcommands: Vec<_> = cmd.get_subcommands().collect(); + + // Define command groupings and their order (excluding MISC) + let groups = [ + ("INSPECTION", vec!["log", "status"]), + ( + "STACK OPERATIONS", + vec!["commit", "rub", "new", "describe", "branch"], + ), + ("OPERATION HISTORY", vec!["oplog", "undo", "restore"]), + ]; + + println!("A GitButler CLI tool"); + println!(); + println!("Usage: but [OPTIONS] "); + println!(); + + // Keep track of which commands we've already printed + let mut printed_commands = HashSet::new(); + + // Print grouped commands + for (group_name, command_names) in &groups { + println!("{}:", group_name); + for cmd_name in command_names { + if let Some(subcmd) = subcommands.iter().find(|c| c.get_name() == *cmd_name) { + let about = subcmd.get_about().unwrap_or_default(); + println!(" {:<10}{}", cmd_name, about); + printed_commands.insert(cmd_name.to_string()); + } + } + println!(); + } + + // Collect any remaining commands not in the explicit groups + let misc_commands: Vec<_> = subcommands + .iter() + .filter(|subcmd| !printed_commands.contains(subcmd.get_name()) && !subcmd.is_hide_set()) + .collect(); + + // Print MISC section if there are any ungrouped commands + if !misc_commands.is_empty() { + println!("MISC:"); + for subcmd in misc_commands { + let about = subcmd.get_about().unwrap_or_default(); + println!(" {:<10}{}", subcmd.get_name(), about); + } + println!(); + } + + println!("Options:"); + println!( + " -C, --current-dir Run as if gitbutler-cli was started in PATH instead of the current working directory [default: .]" + ); + println!(" -j, --json Whether to use JSON output format"); + println!(" -h, --help Print help"); +} 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/new/mod.rs b/crates/but/src/new/mod.rs new file mode 100644 index 0000000000..5a8974f297 --- /dev/null +++ b/crates/but/src/new/mod.rs @@ -0,0 +1,149 @@ +use crate::id::CliId; +use anyhow::Result; +use but_settings::AppSettings; +use gitbutler_command_context::CommandContext; +use gitbutler_oxidize::ObjectIdExt; +use gitbutler_project::Project; +use std::path::Path; + +pub(crate) fn insert_blank_commit(repo_path: &Path, _json: bool, target: &str) -> Result<()> { + let project = Project::from_path(repo_path)?; + let mut ctx = CommandContext::open(&project, AppSettings::load_from_default_path_creating()?)?; + + // Resolve the target ID + let cli_ids = CliId::from_str(&mut ctx, target)?; + + if cli_ids.is_empty() { + anyhow::bail!("Target '{}' not found", target); + } + + if cli_ids.len() > 1 { + anyhow::bail!( + "Target '{}' is ambiguous. Found {} matches", + target, + cli_ids.len() + ); + } + + let cli_id = &cli_ids[0]; + + match cli_id { + CliId::Commit { oid } => { + // Insert blank commit before this specific commit + insert_before_commit(&ctx, *oid)?; + } + CliId::Branch { name } => { + // Insert blank commit at the top of this stack + insert_at_top_of_stack(&ctx, name)?; + } + _ => { + anyhow::bail!( + "Target must be a commit ID or branch name, not {}", + cli_id.kind() + ); + } + } + + Ok(()) +} + +fn insert_before_commit(ctx: &CommandContext, commit_oid: gix::ObjectId) -> Result<()> { + // Find which stack this commit belongs to + let stacks = crate::log::stacks(ctx)?; + + for stack_entry in &stacks { + if let Some(stack_id) = stack_entry.id { + let stack_details = crate::log::stack_details(ctx, stack_id)?; + + // Check if this commit exists in any branch of this stack + for branch_details in &stack_details.branch_details { + for commit in &branch_details.commits { + if commit.id == commit_oid { + // Found the commit - insert blank commit before it + let (new_commit_id, _changes) = + gitbutler_branch_actions::insert_blank_commit( + ctx, + stack_id, + commit_oid.to_git2(), + 0, // offset: 0 means "before this commit" + Some(""), // Empty commit message + )?; + + println!( + "Created blank commit {} before commit {}", + &new_commit_id.to_string()[..7], + &commit_oid.to_string()[..7] + ); + return Ok(()); + } + } + + // Also check upstream commits + for commit in &branch_details.upstream_commits { + if commit.id == commit_oid { + let (new_commit_id, _changes) = + gitbutler_branch_actions::insert_blank_commit( + ctx, + stack_id, + commit_oid.to_git2(), + 0, // offset: 0 means "before this commit" + Some(""), // Empty commit message + )?; + + println!( + "Created blank commit {} before commit {}", + &new_commit_id.to_string()[..7], + &commit_oid.to_string()[..7] + ); + return Ok(()); + } + } + } + } + } + + anyhow::bail!("Commit {} not found in any stack", commit_oid); +} + +fn insert_at_top_of_stack(ctx: &CommandContext, branch_name: &str) -> Result<()> { + // Find the stack that contains this branch + let stacks = crate::log::stacks(ctx)?; + + for stack_entry in &stacks { + if let Some(stack_id) = stack_entry.id { + let stack_details = crate::log::stack_details(ctx, stack_id)?; + + // Check if this branch exists in this stack + for branch_details in &stack_details.branch_details { + if branch_details.name.to_string() == branch_name { + // Get the head commit of this branch + let head_commit = if let Some(commit) = branch_details.commits.first() { + commit.id + } else if let Some(commit) = branch_details.upstream_commits.first() { + commit.id + } else { + anyhow::bail!("Branch '{}' has no commits", branch_name); + }; + + // Insert blank commit at the top (after the head commit) + let (new_commit_id, _changes) = gitbutler_branch_actions::insert_blank_commit( + ctx, + stack_id, + head_commit.to_git2(), + -1, // offset: -1 means "after this commit" (at the top) + Some(""), // Empty commit message + )?; + + println!( + "Created blank commit {} at the top of stack '{}'", + &new_commit_id.to_string()[..7], + branch_name + ); + return Ok(()); + } + } + } + } + + anyhow::bail!("Branch '{}' not found in any stack", branch_name); +} diff --git a/crates/but/src/oplog/mod.rs b/crates/but/src/oplog/mod.rs new file mode 100644 index 0000000000..e76f846986 --- /dev/null +++ b/crates/but/src/oplog/mod.rs @@ -0,0 +1,120 @@ +use but_settings::AppSettings; +use colored::Colorize; +use gitbutler_command_context::CommandContext; +use gitbutler_oplog::{OplogExt, entry::OperationKind}; +use gitbutler_project::Project; +use std::path::Path; + +pub(crate) fn show_oplog(repo_path: &Path, json: bool, since: Option<&str>) -> anyhow::Result<()> { + let project = Project::from_path(repo_path)?; + let ctx = CommandContext::open(&project, AppSettings::load_from_default_path_creating()?)?; + + let snapshots = if let Some(since_sha) = since { + // Get all snapshots first to find the starting point + let all_snapshots = ctx.list_snapshots(1000, None, vec![])?; // Get a large number to find the SHA + let mut found_index = None; + + // Find the snapshot that matches the since SHA (partial match supported) + for (index, snapshot) in all_snapshots.iter().enumerate() { + let snapshot_sha = snapshot.commit_id.to_string(); + if snapshot_sha.starts_with(since_sha) { + found_index = Some(index); + break; + } + } + + match found_index { + Some(index) => { + // Take 20 entries starting from the found index + all_snapshots.into_iter().skip(index).take(20).collect() + } + None => { + return Err(anyhow::anyhow!( + "No oplog entry found matching SHA: {}", + since_sha + )); + } + } + } else { + ctx.list_snapshots(20, None, vec![])? + }; + + if snapshots.is_empty() { + if json { + println!("[]"); + } else { + println!("No operations found in history."); + } + return Ok(()); + } + + if json { + // Output JSON format + let json_output = serde_json::to_string_pretty(&snapshots)?; + println!("{}", json_output); + } else { + // Output human-readable format + println!("{}", "Operations History".blue().bold()); + println!("{}", "─".repeat(50).dimmed()); + + for snapshot in snapshots { + let time_string = chrono::DateTime::from_timestamp(snapshot.created_at.seconds(), 0) + .ok_or(anyhow::anyhow!("Could not parse timestamp"))? + .format("%Y-%m-%d %H:%M:%S") + .to_string(); + + let commit_id = format!( + "{}{}", + &snapshot.commit_id.to_string()[..7].blue().underline(), + &snapshot.commit_id.to_string()[7..12].blue().dimmed() + ); + + let (operation_type, title) = if let Some(details) = &snapshot.details { + let op_type = match details.operation { + OperationKind::CreateCommit => "CREATE", + OperationKind::CreateBranch => "BRANCH", + OperationKind::AmendCommit => "AMEND", + OperationKind::UndoCommit => "UNDO", + OperationKind::SquashCommit => "SQUASH", + OperationKind::UpdateCommitMessage => "REWORD", + OperationKind::MoveCommit => "MOVE", + OperationKind::RestoreFromSnapshot => "RESTORE", + OperationKind::ReorderCommit => "REORDER", + OperationKind::InsertBlankCommit => "INSERT", + OperationKind::MoveHunk => "MOVE_HUNK", + OperationKind::ReorderBranches => "REORDER_BRANCH", + OperationKind::UpdateWorkspaceBase => "UPDATE_BASE", + OperationKind::UpdateBranchName => "RENAME", + OperationKind::GenericBranchUpdate => "BRANCH_UPDATE", + OperationKind::ApplyBranch => "APPLY", + OperationKind::UnapplyBranch => "UNAPPLY", + OperationKind::DeleteBranch => "DELETE", + OperationKind::DiscardChanges => "DISCARD", + _ => "OTHER", + }; + (op_type, details.title.clone()) + } else { + ("UNKNOWN", "Unknown operation".to_string()) + }; + + let operation_colored = match operation_type { + "CREATE" => operation_type.green(), + "AMEND" | "REWORD" => operation_type.yellow(), + "UNDO" | "RESTORE" => operation_type.red(), + "BRANCH" | "CHECKOUT" => operation_type.purple(), + "MOVE" | "REORDER" | "MOVE_HUNK" => operation_type.cyan(), + _ => operation_type.normal(), + }; + + println!( + "{} {} {} {}", + commit_id, + time_string.dimmed(), + format!("[{}]", operation_colored), + title + ); + } + } + + Ok(()) +} diff --git a/crates/but/src/restore/mod.rs b/crates/but/src/restore/mod.rs new file mode 100644 index 0000000000..094f93d7ce --- /dev/null +++ b/crates/but/src/restore/mod.rs @@ -0,0 +1,104 @@ +use but_settings::AppSettings; +use colored::Colorize; +use gitbutler_command_context::CommandContext; +use gitbutler_oplog::OplogExt; +use gitbutler_project::Project; +use std::path::Path; + +pub(crate) fn restore_to_oplog( + repo_path: &Path, + _json: bool, + oplog_sha: &str, +) -> anyhow::Result<()> { + let project = Project::from_path(repo_path)?; + let ctx = CommandContext::open(&project, AppSettings::load_from_default_path_creating()?)?; + + // Parse the oplog SHA (support partial SHAs) + let commit_id = if oplog_sha.len() >= 7 { + // Try to find a snapshot that starts with this SHA + let snapshots = ctx.list_snapshots(100, None, vec![])?; + + let matching_snapshot = snapshots + .iter() + .find(|snapshot| snapshot.commit_id.to_string().starts_with(oplog_sha)) + .ok_or_else(|| anyhow::anyhow!("No oplog snapshot found matching '{}'", oplog_sha))?; + + matching_snapshot.commit_id + } else { + anyhow::bail!("Oplog SHA must be at least 7 characters long"); + }; + + // Get information about the target snapshot + let snapshots = ctx.list_snapshots(100, None, vec![])?; + let target_snapshot = snapshots + .iter() + .find(|snapshot| snapshot.commit_id == commit_id) + .ok_or_else(|| anyhow::anyhow!("Snapshot {} not found in oplog", commit_id))?; + + let target_operation = target_snapshot + .details + .as_ref() + .map(|d| d.title.as_str()) + .unwrap_or("Unknown operation"); + + let target_time = chrono::DateTime::from_timestamp(target_snapshot.created_at.seconds(), 0) + .ok_or(anyhow::anyhow!("Could not parse timestamp"))? + .format("%Y-%m-%d %H:%M:%S") + .to_string(); + + println!("{}", "Restoring to oplog snapshot...".blue().bold()); + println!( + " Target: {} ({})", + target_operation.green(), + target_time.dimmed() + ); + println!( + " Snapshot: {}", + commit_id.to_string()[..7].cyan().underline() + ); + + // Confirm the restoration (safety check) + println!( + "\n{}", + "⚠️ This will overwrite your current workspace state." + .yellow() + .bold() + ); + print!("Continue with restore? [y/N]: "); + use std::io::{self, Write}; + io::stdout().flush()?; + + let mut input = String::new(); + io::stdin().read_line(&mut input)?; + + let input = input.trim().to_lowercase(); + if input != "y" && input != "yes" { + println!("{}", "Restore cancelled.".yellow()); + return Ok(()); + } + + // Get exclusive access to the worktree + let mut guard = project.exclusive_worktree_access(); + + // Restore to the target snapshot + let restore_commit_id = ctx.restore_snapshot(commit_id, guard.write_permission())?; + + let restore_commit_short = format!( + "{}{}", + &restore_commit_id.to_string()[..7].blue().underline(), + &restore_commit_id.to_string()[7..12].blue().dimmed() + ); + + println!( + "\n{} Restore completed successfully! New snapshot: {}", + "✓".green().bold(), + restore_commit_short + ); + + println!( + "{}", + "\nWorkspace has been restored to the selected snapshot.".green() + ); + + Ok(()) +} diff --git a/crates/but/src/rub/assign.rs b/crates/but/src/rub/assign.rs index 84b4d86389..708c2e30d3 100644 --- a/crates/but/src/rub/assign.rs +++ b/crates/but/src/rub/assign.rs @@ -3,8 +3,6 @@ use but_workspace::StackId; use colored::Colorize; use gitbutler_command_context::CommandContext; -use crate::command; - pub(crate) fn assign_file_to_branch( ctx: &mut CommandContext, path: &str, @@ -79,7 +77,12 @@ fn do_assignments( ) -> anyhow::Result<()> { let rejections = but_hunk_assignment::assign(ctx, reqs, None)?; if !rejections.is_empty() { - command::print(&rejections, false)?; + // Don't print the debug output, instead provide a clear error + anyhow::bail!( + "Cannot assign file - it is locked to {} commit{}. Files are locked when they have changes in commits that conflict with the requested assignment. Use git commands to modify commits or move the changes.", + if rejections.len() == 1 { "a" } else { "other" }, + if rejections.len() == 1 { "" } else { "s" } + ); } Ok(()) } diff --git a/crates/but/src/rub/commits.rs b/crates/but/src/rub/commits.rs new file mode 100644 index 0000000000..89e0e49a1e --- /dev/null +++ b/crates/but/src/rub/commits.rs @@ -0,0 +1,127 @@ +use std::collections::HashSet; + +use anyhow::{Context, Result}; +use bstr::ByteSlice; +use but_core::diff::tree_changes; +use but_hunk_assignment::HunkAssignmentRequest; +use but_workspace::DiffSpec; +use gitbutler_branch_actions::update_workspace_commit; +use gitbutler_command_context::CommandContext; +use gitbutler_stack::VirtualBranchesHandle; + +use crate::rub::{assign::branch_name_to_stack_id, undo::stack_id_by_commit_id}; + +pub fn commited_file_to_another_commit( + ctx: &mut CommandContext, + path: &str, + source_id: gix::ObjectId, + target_id: gix::ObjectId, +) -> Result<()> { + let source_stack = stack_id_by_commit_id(ctx, &source_id)?; + let target_stack = stack_id_by_commit_id(ctx, &target_id)?; + + let repo = ctx.gix_repo()?; + let source_commit = repo.find_commit(source_id)?; + let source_commit_parent_id = source_commit.parent_ids().next().context("First parent")?; + + let (tree_changes, _) = tree_changes(&repo, Some(source_commit_parent_id.detach()), source_id)?; + let relevant_changes = tree_changes + .into_iter() + .filter(|tc| tc.path.to_str_lossy() == path) + .map(Into::into) + .collect::>(); + + but_workspace::move_changes_between_commits( + ctx, + source_stack, + source_id, + target_stack, + target_id, + relevant_changes, + ctx.app_settings().context_lines, + )?; + + let vb_state = VirtualBranchesHandle::new(ctx.project().gb_dir()); + update_workspace_commit(&vb_state, &ctx)?; + + println!("Moved files between commits!"); + + Ok(()) +} + +pub fn commited_file_to_unassigned_stack( + ctx: &mut CommandContext, + path: &str, + source_id: gix::ObjectId, + target_branch: &str, +) -> Result<()> { + let source_stack = stack_id_by_commit_id(ctx, &source_id)?; + let target_stack = branch_name_to_stack_id(ctx, Some(target_branch))?; + + let repo = ctx.gix_repo()?; + + let source_commit = repo.find_commit(source_id)?; + let source_commit_parent_id = source_commit.parent_ids().next().context("First parent")?; + + let (tree_changes, _) = tree_changes(&repo, Some(source_commit_parent_id.detach()), source_id)?; + let relevant_changes = tree_changes + .into_iter() + .filter(|tc| tc.path.to_str_lossy() == path) + .map(Into::into) + .collect::>(); + + // If we want to assign the changes after uncommitting, we could try to do + // something with the hunk headers, but this is not precise as the hunk + // headers might have changed from what they were like when they were + // committed. + // + // As such, we take all the old assignments, and all the new assignments from after the + // uncommit, and find the ones that are not present in the old assignments. + // We then convert those into assignment requests for the given stack. + let before_assignments = but_hunk_assignment::assignments_with_fallback( + ctx, + false, + None::>, + None, + )? + .0; + + but_workspace::remove_changes_from_commit_in_stack( + &ctx, + source_stack, + source_id, + relevant_changes, + ctx.app_settings().context_lines, + )?; + + let vb_state = VirtualBranchesHandle::new(ctx.project().gb_dir()); + update_workspace_commit(&vb_state, ctx)?; + + let (after_assignments, _) = but_hunk_assignment::assignments_with_fallback( + ctx, + false, + None::>, + None, + )?; + + let before_assignments = before_assignments + .into_iter() + .filter_map(|a| a.id) + .collect::>(); + + let to_assign = after_assignments + .into_iter() + .filter(|a| a.id.is_some_and(|id| !before_assignments.contains(&id))) + .map(|a| HunkAssignmentRequest { + hunk_header: a.hunk_header, + path_bytes: a.path_bytes, + stack_id: target_stack, + }) + .collect::>(); + + but_hunk_assignment::assign(ctx, to_assign, None)?; + + println!("Uncommitted changes"); + + Ok(()) +} diff --git a/crates/but/src/rub/mod.rs b/crates/but/src/rub/mod.rs index e0187242cf..612798886c 100644 --- a/crates/but/src/rub/mod.rs +++ b/crates/but/src/rub/mod.rs @@ -1,14 +1,21 @@ 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; +use gitbutler_oplog::{ + OplogExt, + entry::{OperationKind, SnapshotDetails}, +}; use gitbutler_project::Project; mod amend; mod assign; +mod commits; mod move_commit; mod squash; +mod uncommit; mod undo; use crate::id::CliId; @@ -21,50 +28,119 @@ pub(crate) fn handle( ) -> 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 (source, target) = ids(ctx, source_str, target_str)?; + let (sources, target) = ids(ctx, source_str, target_str)?; - match (&source, &target) { - (CliId::UncommittedFile { .. }, CliId::UncommittedFile { .. }) => { - bail!(makes_no_sense_error(&source, &target)) - } - (CliId::UncommittedFile { path, .. }, CliId::Unassigned) => { - assign::unassign_file(ctx, path) - } - (CliId::UncommittedFile { path, assignment }, CliId::Commit { oid }) => { - amend::file_to_commit(ctx, path, *assignment, oid) - } - (CliId::UncommittedFile { path, .. }, CliId::Branch { name }) => { - assign::assign_file_to_branch(ctx, path, name) - } - (CliId::Unassigned, CliId::UncommittedFile { .. }) => { - bail!(makes_no_sense_error(&source, &target)) - } - (CliId::Unassigned, CliId::Unassigned) => { - bail!(makes_no_sense_error(&source, &target)) - } - (CliId::Unassigned, CliId::Commit { oid }) => amend::assignments_to_commit(ctx, None, oid), - (CliId::Unassigned, CliId::Branch { name: to }) => assign::assign_all(ctx, None, Some(to)), - (CliId::Commit { .. }, CliId::UncommittedFile { .. }) => { - bail!(makes_no_sense_error(&source, &target)) - } - (CliId::Commit { oid }, CliId::Unassigned) => undo::commit(ctx, oid), - (CliId::Commit { oid: source }, CliId::Commit { oid: destination }) => { - squash::commits(ctx, source, destination) - } - (CliId::Commit { oid }, CliId::Branch { name }) => move_commit::to_branch(ctx, oid, name), - (CliId::Branch { .. }, CliId::UncommittedFile { .. }) => { - bail!(makes_no_sense_error(&source, &target)) - } - (CliId::Branch { name: from }, CliId::Unassigned) => { - assign::assign_all(ctx, Some(from), None) - } - (CliId::Branch { name }, CliId::Commit { oid }) => { - amend::assignments_to_commit(ctx, Some(name), oid) - } - (CliId::Branch { name: from }, CliId::Branch { name: to }) => { - assign::assign_all(ctx, Some(from), Some(to)) + // Process each source with the target + for source in sources { + match (&source, &target) { + (CliId::UncommittedFile { .. }, CliId::UncommittedFile { .. }) => { + bail!(makes_no_sense_error(&source, &target)) + } + (CliId::UncommittedFile { path, .. }, CliId::Unassigned) => { + create_snapshot(ctx, &project, OperationKind::MoveHunk); + assign::unassign_file(ctx, path)?; + } + (CliId::UncommittedFile { path, assignment }, CliId::Commit { oid }) => { + create_snapshot(ctx, &project, OperationKind::AmendCommit); + amend::file_to_commit(ctx, path, *assignment, oid)?; + } + (CliId::UncommittedFile { path, .. }, CliId::Branch { name }) => { + create_snapshot(ctx, &project, OperationKind::MoveHunk); + assign::assign_file_to_branch(ctx, path, name)?; + } + (CliId::UncommittedFile { .. }, CliId::CommittedFile { .. }) => { + bail!(makes_no_sense_error(&source, &target)) + } + // CommittedFile operations + (CliId::CommittedFile { .. }, CliId::UncommittedFile { .. }) => { + bail!(makes_no_sense_error(&source, &target)) + } + (CliId::CommittedFile { .. }, CliId::CommittedFile { .. }) => { + bail!(makes_no_sense_error(&source, &target)) + } + (CliId::CommittedFile { path, commit_oid }, CliId::Unassigned) => { + create_snapshot(ctx, &project, OperationKind::FileChanges); + uncommit::file_from_commit(ctx, path, commit_oid)?; + } + ( + CliId::CommittedFile { + path, + commit_oid: source_id, + }, + CliId::Branch { + name: target_branch, + }, + ) => { + create_snapshot(ctx, &project, OperationKind::FileChanges); + commits::commited_file_to_unassigned_stack(ctx, path, *source_id, target_branch)?; + } + ( + CliId::CommittedFile { + path, + commit_oid: source_id, + }, + CliId::Commit { oid: target_id }, + ) => { + create_snapshot(ctx, &project, OperationKind::FileChanges); + commits::commited_file_to_another_commit(ctx, path, *source_id, *target_id)?; + } + (CliId::Unassigned, CliId::UncommittedFile { .. }) => { + bail!(makes_no_sense_error(&source, &target)) + } + (CliId::Unassigned, CliId::Unassigned) => { + bail!(makes_no_sense_error(&source, &target)) + } + (CliId::Unassigned, CliId::Commit { oid }) => { + create_snapshot(ctx, &project, OperationKind::AmendCommit); + amend::assignments_to_commit(ctx, None, oid)?; + } + (CliId::Unassigned, CliId::Branch { name: to }) => { + create_snapshot(ctx, &project, OperationKind::MoveHunk); + assign::assign_all(ctx, None, Some(to))?; + } + (CliId::Unassigned, CliId::CommittedFile { .. }) => { + bail!(makes_no_sense_error(&source, &target)) + } + (CliId::Commit { .. }, CliId::UncommittedFile { .. }) => { + bail!(makes_no_sense_error(&source, &target)) + } + (CliId::Commit { .. }, CliId::CommittedFile { .. }) => { + bail!(makes_no_sense_error(&source, &target)) + } + (CliId::Commit { oid }, CliId::Unassigned) => { + create_snapshot(ctx, &project, OperationKind::UndoCommit); + undo::commit(ctx, oid)?; + } + (CliId::Commit { oid: source_oid }, CliId::Commit { oid: destination }) => { + create_snapshot(ctx, &project, OperationKind::SquashCommit); + squash::commits(ctx, source_oid, destination)?; + } + (CliId::Commit { oid }, CliId::Branch { name }) => { + create_snapshot(ctx, &project, OperationKind::MoveCommit); + move_commit::to_branch(ctx, oid, name)?; + } + (CliId::Branch { .. }, CliId::UncommittedFile { .. }) => { + bail!(makes_no_sense_error(&source, &target)) + } + (CliId::Branch { .. }, CliId::CommittedFile { .. }) => { + bail!(makes_no_sense_error(&source, &target)) + } + (CliId::Branch { name: from }, CliId::Unassigned) => { + create_snapshot(ctx, &project, OperationKind::MoveHunk); + assign::assign_all(ctx, Some(from), None)?; + } + (CliId::Branch { name }, CliId::Commit { oid }) => { + create_snapshot(ctx, &project, OperationKind::AmendCommit); + amend::assignments_to_commit(ctx, Some(name), oid)?; + } + (CliId::Branch { name: from }, CliId::Branch { name: to }) => { + create_snapshot(ctx, &project, OperationKind::MoveHunk); + assign::assign_all(ctx, Some(from), Some(to))?; + } } } + + Ok(()) } fn makes_no_sense_error(source: &CliId, target: &CliId) -> String { @@ -77,22 +153,225 @@ fn makes_no_sense_error(source: &CliId, target: &CliId) -> String { ) } -fn ids(ctx: &mut CommandContext, source: &str, target: &str) -> anyhow::Result<(CliId, CliId)> { - let source_result = crate::id::CliId::from_str(ctx, source)?; - if source_result.len() != 1 { +fn ids( + ctx: &mut CommandContext, + source: &str, + target: &str, +) -> anyhow::Result<(Vec, CliId)> { + let sources = parse_sources(ctx, source)?; + let target_result = crate::id::CliId::from_str(ctx, target)?; + if target_result.len() != 1 { + if target_result.is_empty() { + return Err(anyhow::anyhow!( + "Target '{}' not found. If you just performed a Git operation (squash, rebase, etc.), try running 'but status' to refresh the current state.", + target + )); + } else { + let matches: Vec = target_result + .iter() + .map(|id| match id { + CliId::Commit { oid } => { + format!("{} (commit {})", id.to_string(), &oid.to_string()[..7]) + } + CliId::Branch { name } => format!("{} (branch '{}')", id.to_string(), name), + _ => format!("{} ({})", id.to_string(), id.kind()), + }) + .collect(); + return Err(anyhow::anyhow!( + "Target '{}' is ambiguous. Matches: {}. Try using more characters, a longer SHA, or the full branch name to disambiguate.", + target, + matches.join(", ") + )); + } + } + Ok((sources, target_result[0].clone())) +} + +fn parse_sources(ctx: &mut CommandContext, source: &str) -> anyhow::Result> { + // Check if it's a range (contains '-') + if source.contains('-') { + parse_range(ctx, source) + } + // Check if it's a list (contains ',') + else if source.contains(',') { + parse_list(ctx, source) + } + // Single source + else { + let source_result = crate::id::CliId::from_str(ctx, source)?; + if source_result.len() != 1 { + if source_result.is_empty() { + return Err(anyhow::anyhow!( + "Source '{}' not found. If you just performed a Git operation (squash, rebase, etc.), try running 'but status' to refresh the current state.", + source + )); + } else { + let matches: Vec = source_result + .iter() + .map(|id| match id { + CliId::Commit { oid } => { + format!("{} (commit {})", id.to_string(), &oid.to_string()[..7]) + } + CliId::Branch { name } => format!("{} (branch '{}')", id.to_string(), name), + _ => format!("{} ({})", id.to_string(), id.kind()), + }) + .collect(); + return Err(anyhow::anyhow!( + "Source '{}' is ambiguous. Matches: {}. Try using more characters, a longer SHA, or the full branch name to disambiguate.", + source, + matches.join(", ") + )); + } + } + Ok(vec![source_result[0].clone()]) + } +} + +fn parse_range(ctx: &mut CommandContext, source: &str) -> anyhow::Result> { + let parts: Vec<&str> = source.split('-').collect(); + if parts.len() != 2 { return Err(anyhow::anyhow!( - "Source {} is ambiguous: {:?}", - source, - source_result + "Range format should be 'start-end', got '{}'", + source )); } - let target_result = crate::id::CliId::from_str(ctx, target)?; - if target_result.len() != 1 { + + let start_str = parts[0]; + let end_str = parts[1]; + + // Get the start and end IDs + let start_matches = crate::id::CliId::from_str(ctx, start_str)?; + let end_matches = crate::id::CliId::from_str(ctx, end_str)?; + + if start_matches.len() != 1 { + return Err(anyhow::anyhow!( + "Start of range '{}' must match exactly one item", + start_str + )); + } + if end_matches.len() != 1 { return Err(anyhow::anyhow!( - "Target {} is ambiguous: {:?}", - target, - target_result + "End of range '{}' must match exactly one item", + end_str )); } - Ok((source_result[0].clone(), target_result[0].clone())) + + let start_id = &start_matches[0]; + let end_id = &end_matches[0]; + + // Get all files in display order (same order as shown in status) + let all_files_in_order = get_all_files_in_display_order(ctx)?; + + // Find the positions of start and end in the ordered file list + let start_pos = all_files_in_order.iter().position(|id| id == start_id); + let end_pos = all_files_in_order.iter().position(|id| id == end_id); + + if let (Some(start_idx), Some(end_idx)) = (start_pos, end_pos) { + if start_idx <= end_idx { + return Ok(all_files_in_order[start_idx..=end_idx].to_vec()); + } else { + return Ok(all_files_in_order[end_idx..=start_idx].to_vec()); + } + } + + Err(anyhow::anyhow!( + "Could not find range from '{}' to '{}' in the displayed file list", + start_str, + end_str + )) +} + +fn get_all_files_in_display_order(ctx: &mut CommandContext) -> anyhow::Result> { + use bstr::BString; + use but_hunk_assignment::HunkAssignment; + use std::collections::BTreeMap; + + let project = gitbutler_project::Project::from_path(&ctx.project().path)?; + let changes = + but_core::diff::ui::worktree_changes_by_worktree_dir(project.path.clone())?.changes; + let (assignments, _) = + but_hunk_assignment::assignments_with_fallback(ctx, false, Some(changes.clone()), None)?; + + // Group assignments by file, same as status display logic + let mut by_file: BTreeMap> = BTreeMap::new(); + for assignment in &assignments { + by_file + .entry(assignment.path_bytes.clone()) + .or_default() + .push(assignment.clone()); + } + + let mut all_files = Vec::new(); + + // First, get files assigned to branches (they appear first in status display) + let stacks = crate::log::stacks(ctx)?; + for stack in stacks { + if let Some((_stack_id, details_result)) = stack + .id + .map(|id| (stack.id, crate::log::stack_details(ctx, id))) + && let Ok(details) = details_result + { + for _branch in &details.branch_details { + for (_path_bytes, assignments) in &by_file { + for assignment in assignments { + if let Some(stack_id) = assignment.stack_id + && stack.id == Some(stack_id) + { + let file_id = CliId::file_from_assignment(assignment); + if !all_files.contains(&file_id) { + all_files.push(file_id); + } + } + } + } + } + } + } + + // Then add unassigned files (they appear last in status display) + for (_path_bytes, assignments) in &by_file { + for assignment in assignments { + if assignment.stack_id.is_none() { + let file_id = CliId::file_from_assignment(assignment); + if !all_files.contains(&file_id) { + all_files.push(file_id); + } + } + } + } + + Ok(all_files) +} + +fn parse_list(ctx: &mut CommandContext, source: &str) -> anyhow::Result> { + let parts: Vec<&str> = source.split(',').collect(); + let mut result = Vec::new(); + + for part in parts { + let part = part.trim(); + let matches = crate::id::CliId::from_str(ctx, part)?; + if matches.len() != 1 { + if matches.is_empty() { + return Err(anyhow::anyhow!( + "Item '{}' in list not found. If you just performed a Git operation (squash, rebase, etc.), try running 'but status' to refresh the current state.", + part + )); + } else { + return Err(anyhow::anyhow!( + "Item '{}' in list is ambiguous. Try using more characters to disambiguate.", + part + )); + } + } + result.push(matches[0].clone()); + } + + Ok(result) +} + +fn create_snapshot(ctx: &mut CommandContext, project: &Project, operation: OperationKind) { + let mut guard = project.exclusive_worktree_access(); + let _snapshot = ctx + .create_snapshot(SnapshotDetails::new(operation), guard.write_permission()) + .ok(); // Ignore errors for snapshot creation } diff --git a/crates/but/src/rub/squash.rs b/crates/but/src/rub/squash.rs index 1bfd75f2bf..b7c09ed102 100644 --- a/crates/but/src/rub/squash.rs +++ b/crates/but/src/rub/squash.rs @@ -10,8 +10,16 @@ pub(crate) fn commits( source: &ObjectId, destination: &ObjectId, ) -> anyhow::Result<()> { - let source_stack = stack_id_by_commit_id(ctx, source)?; - let destination_stack = stack_id_by_commit_id(ctx, destination)?; + // Validate both commits exist in stacks before proceeding + let source_stack = stack_id_by_commit_id(ctx, source) + .map_err(|e| anyhow::anyhow!("Source commit {}: {}", &source.to_string()[..7], e))?; + let destination_stack = stack_id_by_commit_id(ctx, destination).map_err(|e| { + anyhow::anyhow!( + "Destination commit {}: {}", + &destination.to_string()[..7], + e + ) + })?; if source_stack != destination_stack { anyhow::bail!("Cannot squash commits from different stacks"); } diff --git a/crates/but/src/rub/uncommit.rs b/crates/but/src/rub/uncommit.rs new file mode 100644 index 0000000000..ca907f0339 --- /dev/null +++ b/crates/but/src/rub/uncommit.rs @@ -0,0 +1,29 @@ +use anyhow::Result; +use colored::Colorize; +use gitbutler_command_context::CommandContext; + +pub(crate) fn file_from_commit( + _ctx: &CommandContext, + file_path: &str, + commit_oid: &gix::ObjectId, +) -> Result<()> { + // For now, we'll show a message about what would happen + // The actual implementation would need to: + // 1. Extract the file changes from the commit + // 2. Apply them to the working directory as uncommitted changes + // 3. Remove the file changes from the commit (creating a new commit) + + let commit_short = &commit_oid.to_string()[..7]; + println!( + "Uncommitting {} from commit {}", + file_path.white(), + commit_short.blue() + ); + + // TODO: Implement the actual uncommit logic + // This would involve complex Git operations similar to what the GitButler UI does + anyhow::bail!( + "Uncommitting files from commits is not yet fully implemented. \ + Use the GitButler UI or git commands to extract file changes from commits." + ) +} diff --git a/crates/but/src/rub/undo.rs b/crates/but/src/rub/undo.rs index 547ef56cf8..4a97de17e9 100644 --- a/crates/but/src/rub/undo.rs +++ b/crates/but/src/rub/undo.rs @@ -29,5 +29,8 @@ pub(crate) fn stack_id_by_commit_id( }) { return Ok(*id); } - anyhow::bail!("No stack found for commit {}", oid.to_string()) + anyhow::bail!( + "No stack found for commit {}. The commit may have been removed by a previous operation (e.g., squash, rebase). Try refreshing with 'but status' to see the current state.", + &oid.to_string()[..7] + ) } diff --git a/crates/but/src/status/mod.rs b/crates/but/src/status/mod.rs index 9f45182af0..d7e4520ad8 100644 --- a/crates/but/src/status/mod.rs +++ b/crates/but/src/status/mod.rs @@ -1,36 +1,48 @@ use assignment::FileAssignment; -use bstr::BString; +use bstr::{BString, ByteSlice}; use but_core::ui::{TreeChange, TreeStatus}; use but_hunk_assignment::HunkAssignment; use but_settings::AppSettings; use colored::Colorize; use gitbutler_command_context::CommandContext; use gitbutler_project::Project; +use gitbutler_stack; use std::collections::BTreeMap; use std::path::Path; pub(crate) mod assignment; use crate::id::CliId; +use gitbutler_oxidize::gix_to_git2_oid; -pub(crate) fn worktree(repo_path: &Path, _json: bool) -> anyhow::Result<()> { +pub(crate) fn worktree( + repo_path: &Path, + json: bool, + show_base: bool, + show_files: 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)? + // Get stacks with detailed information + let stack_entries = crate::log::stacks(ctx)?; + let stacks: Vec<( + Option, + but_workspace::ui::StackDetails, + )> = stack_entries .iter() .filter_map(|s| { - s.heads.first().and_then(|head| { - let id = s.id?; - let x = head.name.to_string(); - Some((id, x)) - }) + s.id.map(|id| (s.id, crate::log::stack_details(ctx, id))) + .and_then(|(stack_id, result)| result.ok().map(|details| (stack_id, details))) }) - .collect::>(); + .collect(); - let changes = but_core::diff::ui::worktree_changes_by_worktree_dir(project.path)?.changes; + let changes = + but_core::diff::ui::worktree_changes_by_worktree_dir(project.path.clone())?.changes; let (assignments, _assignments_error) = but_hunk_assignment::assignments_with_fallback(ctx, false, Some(changes.clone()), None)?; + // Group assignments by file let mut by_file: BTreeMap> = BTreeMap::new(); for assignment in &assignments { by_file @@ -45,80 +57,45 @@ pub(crate) fn worktree(repo_path: &Path, _json: bool) -> anyhow::Result<()> { FileAssignment::from_assignments(path, assignments), ); } - if stack_id_to_branch.is_empty() { - println!("No branches found. ¯\\_(ツ)_/¯"); - return Ok(()); - } - let unassigned = assignment::filter_by_stack_id(assignments_by_file.values(), &None); - print_group(None, unassigned, &changes)?; - - 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)?; + // Handle JSON output + if json { + let unassigned = assignment::filter_by_stack_id(assignments_by_file.values(), &None); + return output_json( + &stacks, + &assignments_by_file, + &unassigned, + &changes, + show_base, + show_files, + ctx, + ); } - Ok(()) -} -pub fn print_group( - group: Option<&str>, - assignments: Vec, - changes: &[TreeChange], -) -> anyhow::Result<()> { - let id = if let Some(group) = group { - CliId::branch(group) - } else { - CliId::unassigned() + // Print base information only if requested + if show_base { + print_base_info(ctx)?; + println!(); } - .to_string() - .underline() - .blue(); - let group = &group - .map(|s| format!("[{s}]")) - .unwrap_or("".to_string()); - println!("{} {}", id, group.green().bold()); - for fa in assignments { - let state = status_from_changes(changes, fa.path.clone()); - let path = match state { - Some(state) => match state { - TreeStatus::Addition { .. } => fa.path.to_string().green(), - TreeStatus::Deletion { .. } => fa.path.to_string().red(), - TreeStatus::Modification { .. } => fa.path.to_string().yellow(), - TreeStatus::Rename { .. } => fa.path.to_string().purple(), - }, - None => fa.path.to_string().normal(), - }; - - let id = CliId::file_from_assignment(&fa.assignments[0]) - .to_string() - .underline() - .blue(); - let mut locks = fa - .assignments - .iter() - .flat_map(|a| a.hunk_locks.iter()) - .flatten() - .map(|l| l.commit_id.to_string()) - .collect::>() - .into_iter() - .map(|commit_id| { - format!( - "{}{}", - commit_id[..2].blue().underline(), - commit_id[2..7].blue() - ) - }) - .collect::>() - .join(", "); + // Print branches with commits and assigned files + if !stacks.is_empty() { + print_branch_sections( + &stacks, + &assignments_by_file, + &changes, + &project, + show_files, + ctx, + )?; + } - if !locks.is_empty() { - locks = format!("🔒 {locks}"); - } - println!("{} ({}) {} {}", id, fa.assignments.len(), path, locks); + // Print unassigned files + let unassigned = assignment::filter_by_stack_id(assignments_by_file.values(), &None); + if !unassigned.is_empty() { + print_unassigned_section(unassigned, &changes)?; } - println!(); + Ok(()) } @@ -147,6 +124,98 @@ pub(crate) fn all_branches(ctx: &CommandContext) -> anyhow::Result> { Ok(branches) } +pub(crate) fn all_committed_files(ctx: &mut CommandContext) -> anyhow::Result> { + let mut committed_files = Vec::new(); + + // Get stacks with detailed information + let stack_entries = crate::log::stacks(ctx)?; + let stacks: Vec<( + Option, + but_workspace::ui::StackDetails, + )> = stack_entries + .iter() + .filter_map(|s| { + s.id.map(|id| (s.id, crate::log::stack_details(ctx, id))) + .and_then(|(stack_id, result)| result.ok().map(|details| (stack_id, details))) + }) + .collect(); + + // Iterate through all commits in all branches to get committed files + for (_stack_id, stack) in &stacks { + for branch in &stack.branch_details { + // Process upstream commits + for commit in &branch.upstream_commits { + if let Ok(commit_files) = get_commit_files(ctx, commit.id) { + for (file_path, _status) in commit_files { + committed_files.push(CliId::committed_file(&file_path, commit.id)); + } + } + } + + // Process local commits + for commit in &branch.commits { + if let Ok(commit_files) = get_commit_files(ctx, commit.id) { + for (file_path, _status) in commit_files { + committed_files.push(CliId::committed_file(&file_path, commit.id)); + } + } + } + } + } + + Ok(committed_files) +} + +fn get_commit_files( + ctx: &CommandContext, + commit_id: gix::ObjectId, +) -> anyhow::Result> { + let repo = ctx.repo(); + let git2_oid = gix_to_git2_oid(commit_id); + let commit = repo.find_commit(git2_oid)?; + + // Get the commit's tree and parent's tree for comparison + let commit_tree = commit.tree()?; + + // If this is the first commit, compare against empty tree + let parent_tree = if commit.parent_count() == 0 { + None + } else { + Some(commit.parent(0)?.tree()?) + }; + + let mut files = Vec::new(); + + // Create a diff between parent and current commit + let diff = if let Some(parent_tree) = parent_tree { + repo.diff_tree_to_tree(Some(&parent_tree), Some(&commit_tree), None)? + } else { + repo.diff_tree_to_tree(None, Some(&commit_tree), None)? + }; + + // Collect file changes + diff.foreach( + &mut |delta, _progress| { + if let Some(path) = delta.new_file().path() { + let status = match delta.status() { + git2::Delta::Added => "A", + git2::Delta::Modified => "M", + git2::Delta::Deleted => "D", + git2::Delta::Renamed => "R", + _ => "M", + }; + files.push((path.to_string_lossy().to_string(), status.to_string())); + } + true + }, + None, + None, + None, + )?; + + Ok(files) +} + fn status_from_changes(changes: &[TreeChange], path: BString) -> Option { changes.iter().find_map(|change| { if change.path_bytes == path { @@ -156,3 +225,438 @@ fn status_from_changes(changes: &[TreeChange], path: BString) -> Option anyhow::Result<()> { + // Get base information + let target = + gitbutler_stack::VirtualBranchesHandle::new(ctx.project().gb_dir()).get_default_target()?; + + let base_sha = &target.sha.to_string()[..7]; + println!("📍 Base: {} @ {}", "origin/main".cyan(), base_sha.yellow()); + + // For now, we'll show a placeholder for behind count + // In a real implementation, you'd calculate this by comparing HEAD with the target + println!( + "🔺 You are {} commits behind {}", + "0".red(), + "origin/main".cyan() + ); + println!(" (Run `but base update` to rebase your stack)"); + + Ok(()) +} + +fn print_branch_sections( + stacks: &[( + Option, + but_workspace::ui::StackDetails, + )], + assignments_by_file: &BTreeMap, + changes: &[TreeChange], + project: &Project, + show_files: bool, + ctx: &mut CommandContext, +) -> anyhow::Result<()> { + let nesting = 0; + + for (i, (stack_id, stack)) in stacks.iter().enumerate() { + let mut first_branch = true; + + let mut stack_mark = stack_id + .map(|stack_id| { + if crate::mark::stack_marked(ctx, stack_id).unwrap_or_default() { + Some("◀ Marked ▶".red().bold()) + } else { + None + } + }) + .flatten(); + + for (_j, branch) in stack.branch_details.iter().enumerate() { + let branch_name = branch.name.to_string(); + let branch_id = CliId::branch(&branch_name).to_string().underline().blue(); + + // Determine the connecting character for this branch + let prefix = "│ ".repeat(nesting); + let connector = if first_branch { "╭" } else { "├" }; + + println!( + "{}{} {} [{}] {}", + prefix, + connector, + branch_id, + branch_name.green().bold(), + stack_mark.unwrap_or_default() + ); + stack_mark = None; // Only show the stack mark for the first branch + + // Show assigned files first - only for the first (topmost) branch in a stack + // In GitButler's model, files are assigned to the stack, not individual branches + let has_files = if first_branch { + if let Some(stack_id) = stack_id { + let branch_assignments = assignment::filter_by_stack_id( + assignments_by_file.values(), + &Some(*stack_id), + ); + if !branch_assignments.is_empty() { + for fa in &branch_assignments { + let status_char = get_status_char(&fa.path, changes); + let file_id = CliId::file_from_assignment(&fa.assignments[0]) + .to_string() + .underline() + .blue(); + + println!( + "{}│ {} {} {}", + prefix, + file_id, + status_char, + fa.path.to_string().white() + ); + } + println!("{}│", prefix); + true + } else { + false + } + } else { + false + } + } else { + false + }; + + // Show commits after files + let has_commits = !branch.commits.is_empty() || !branch.upstream_commits.is_empty(); + if has_commits { + // Show upstream commits first + for commit in &branch.upstream_commits { + let commit_short = &commit.id.to_string()[..7]; + let commit_id = CliId::commit(commit.id).to_string().underline().blue(); + let message_line = format_commit_message(&commit.message); + + // Check if this upstream commit also exists in local commits (pushed) + let is_also_local = branch.commits.iter().any(|local| local.id == commit.id); + let status_decoration = if is_also_local { + "P".yellow() // Pushed (exists both upstream and locally) + } else { + "R".red() // Remote-only (upstream only) + }; + + println!( + "{}● {} {} {} {}", + prefix, + status_decoration, + commit_id, + commit_short.blue(), + message_line + ); + + // Show files modified in this commit if -f flag is used + if show_files { + if let Ok(commit_files) = get_commit_files(ctx, commit.id) { + for (file_path, status) in commit_files { + let file_id = CliId::committed_file(&file_path, commit.id) + .to_string() + .underline() + .blue(); + let status_char = match status.as_str() { + "A" => "A".green(), + "M" => "M".yellow(), + "D" => "D".red(), + "R" => "R".purple(), + _ => "M".yellow(), + }; + println!( + "{}│ {} {} {}", + prefix, + file_id, + status_char, + file_path.white() + ); + } + } + } + } + + // Show local commits (but skip ones already shown as upstream) + for commit in &branch.commits { + let marked = + crate::mark::commit_marked(ctx, commit.id.to_string()).unwrap_or_default(); + let mark = if marked { + Some("◀ Marked ▶".red().bold()) + } else { + None + }; + // Skip if this commit was already shown in upstream commits + let already_shown = branch + .upstream_commits + .iter() + .any(|upstream| upstream.id == commit.id); + if already_shown { + continue; + } + + let commit_short = &commit.id.to_string()[..7]; + let commit_id = CliId::commit(commit.id).to_string().underline().blue(); + let message_line = format_commit_message(&commit.message); + + // Local-only commits (not pushed) + let status_decoration = "L".green(); + + println!( + "{}● {} {} {} {} {}", + prefix, + status_decoration, + commit_id, + commit_short.blue(), + message_line, + mark.unwrap_or_default() + ); + + // Show files modified in this commit if -f flag is used + if show_files { + if let Ok(commit_files) = get_commit_files(ctx, commit.id) { + for (file_path, status) in commit_files { + let file_id = CliId::committed_file(&file_path, commit.id) + .to_string() + .underline() + .blue(); + let status_char = match status.as_str() { + "A" => "A".green(), + "M" => "M".yellow(), + "D" => "D".red(), + "R" => "R".purple(), + _ => "M".yellow(), + }; + println!( + "{}│ {} {} {}", + prefix, + file_id, + status_char, + file_path.white() + ); + } + } + } + } + println!("{}│", prefix); + } + + if !has_commits && !has_files { + println!("{}│ (no commits)", prefix); + } + + first_branch = false; + } + + // Close the current stack + if !stack.branch_details.is_empty() { + if i == stacks.len() - 1 { + // Last stack - close with simple ├─╯ + println!("│"); + } else { + // Not the last stack - close with ├─╯ and add blank line + println!("│"); + println!(); + } + } + } + + // Get and display the base commit + let ctx = CommandContext::open(&project, AppSettings::load_from_default_path_creating()?)?; + let common_merge_base = gitbutler_stack::VirtualBranchesHandle::new(ctx.project().gb_dir()) + .get_default_target()? + .sha + .to_string()[..7] + .to_string(); + println!("● {} (base)", common_merge_base); + + Ok(()) +} + +fn print_unassigned_section( + unassigned: Vec, + changes: &[TreeChange], +) -> anyhow::Result<()> { + let unassigned_id = CliId::unassigned().to_string().underline().blue(); + println!("\n{} Unassigned Changes", unassigned_id); + + for fa in unassigned { + let status_char = get_status_char(&fa.path, changes); + let file_id = CliId::file_from_assignment(&fa.assignments[0]) + .to_string() + .underline() + .blue(); + + println!( + "{} {} {}", + file_id, + status_char, + fa.path.to_string().white() + ); + } + + Ok(()) +} + +fn get_status_char(path: &BString, changes: &[TreeChange]) -> colored::ColoredString { + match status_from_changes(changes, path.clone()) { + Some(TreeStatus::Addition { .. }) => "A".green(), + Some(TreeStatus::Modification { .. }) => "M".yellow(), + Some(TreeStatus::Deletion { .. }) => "D".red(), + Some(TreeStatus::Rename { .. }) => "R".purple(), + None => " ".normal(), + } +} + +fn output_json( + stacks: &[( + Option, + but_workspace::ui::StackDetails, + )], + assignments_by_file: &std::collections::BTreeMap, + unassigned: &[FileAssignment], + changes: &[TreeChange], + show_base: bool, + _show_files: bool, + ctx: &CommandContext, +) -> anyhow::Result<()> { + use serde_json::json; + + // Get base information if requested + let base_info = if show_base { + let target = gitbutler_stack::VirtualBranchesHandle::new(ctx.project().gb_dir()) + .get_default_target() + .ok(); + target.map(|t| { + json!({ + "branch": "origin/main", // TODO: Get actual base branch name + "sha": t.sha.to_string()[..7].to_string(), + "behind_count": 0 // TODO: Calculate actual behind count + }) + }) + } else { + None + }; + + // Process stacks + let mut stacks_json = Vec::new(); + for (stack_id, stack_details) in stacks { + let mut branches_json = Vec::new(); + + for branch_details in &stack_details.branch_details { + let branch_name = branch_details.name.to_string(); + let branch_cli_id = CliId::branch(&branch_name).to_string(); + + // Get assigned files for this stack (only for the first branch in the stack) + let assigned_files = if branches_json.is_empty() { + if let Some(stack_id) = stack_id { + assignment::filter_by_stack_id(assignments_by_file.values(), &Some(*stack_id)) + .into_iter() + .map(|fa| { + let status = get_status_string(&fa.path, changes); + let file_cli_id = + CliId::file_from_assignment(&fa.assignments[0]).to_string(); + json!({ + "id": file_cli_id, + "path": fa.path.to_string(), + "status": status + }) + }) + .collect::>() + } else { + Vec::new() + } + } else { + Vec::new() + }; + + // Process commits + let mut commits_json = Vec::new(); + + // Add upstream commits + for commit in &branch_details.upstream_commits { + let commit_cli_id = CliId::commit(commit.id).to_string(); + commits_json.push(json!({ + "id": commit_cli_id, + "sha": commit.id.to_string()[..7].to_string(), + "message": format_commit_message(&commit.message), + "type": "upstream" + })); + } + + // Add local commits + for commit in &branch_details.commits { + let commit_cli_id = CliId::commit(commit.id).to_string(); + commits_json.push(json!({ + "id": commit_cli_id, + "sha": commit.id.to_string()[..7].to_string(), + "message": format_commit_message(&commit.message), + "type": "local" + })); + } + + branches_json.push(json!({ + "id": branch_cli_id, + "name": branch_name, + "assigned_files": assigned_files, + "commits": commits_json + })); + } + + if let Some(stack_id) = stack_id { + stacks_json.push(json!({ + "id": stack_id.to_string(), + "branches": branches_json + })); + } + } + + // Process unassigned files + let unassigned_files: Vec<_> = unassigned + .iter() + .map(|fa| { + let status = get_status_string(&fa.path, changes); + let file_cli_id = CliId::file_from_assignment(&fa.assignments[0]).to_string(); + json!({ + "id": file_cli_id, + "path": fa.path.to_string(), + "status": status + }) + }) + .collect(); + + let output = json!({ + "base": base_info, + "stacks": stacks_json, + "unassigned_files": unassigned_files + }); + + println!("{}", serde_json::to_string_pretty(&output)?); + Ok(()) +} + +fn get_status_string(path: &BString, changes: &[TreeChange]) -> &'static str { + for change in changes { + if change.path_bytes == *path { + return match change.status { + but_core::ui::TreeStatus::Addition { .. } => "A", + but_core::ui::TreeStatus::Modification { .. } => "M", + but_core::ui::TreeStatus::Deletion { .. } => "D", + but_core::ui::TreeStatus::Rename { .. } => "R", + }; + } + } + "M" // fallback +} + +fn format_commit_message(message: &BString) -> String { + let message_str = message.to_str_lossy(); + let message_line = message_str.lines().next().unwrap_or(""); + if message_line.trim().is_empty() { + "(blank message)".to_string() + } else { + message_line.to_string() + } +} diff --git a/crates/but/src/undo/mod.rs b/crates/but/src/undo/mod.rs new file mode 100644 index 0000000000..2bbfa07253 --- /dev/null +++ b/crates/but/src/undo/mod.rs @@ -0,0 +1,60 @@ +use but_settings::AppSettings; +use colored::Colorize; +use gitbutler_command_context::CommandContext; +use gitbutler_oplog::OplogExt; +use gitbutler_project::Project; +use std::path::Path; + +pub(crate) fn undo_last_operation(repo_path: &Path, _json: bool) -> anyhow::Result<()> { + let project = Project::from_path(repo_path)?; + let ctx = CommandContext::open(&project, AppSettings::load_from_default_path_creating()?)?; + + // Get the last two snapshots - restore to the second one back + let snapshots = ctx.list_snapshots(2, None, vec![])?; + + if snapshots.len() < 2 { + println!("{}", "No previous operations to undo.".yellow()); + return Ok(()); + } + + let target_snapshot = &snapshots[1]; + + let target_operation = target_snapshot + .details + .as_ref() + .map(|d| d.title.as_str()) + .unwrap_or("Unknown operation"); + + let target_time = chrono::DateTime::from_timestamp(target_snapshot.created_at.seconds(), 0) + .ok_or(anyhow::anyhow!("Could not parse timestamp"))? + .format("%Y-%m-%d %H:%M:%S") + .to_string(); + + println!("{}", "Undoing operation...".blue().bold()); + println!( + " Reverting to: {} ({})", + target_operation.green(), + target_time.dimmed() + ); + + // Get exclusive access to the worktree + let mut guard = project.exclusive_worktree_access(); + + // Restore to the previous snapshot + let restore_commit_id = + ctx.restore_snapshot(target_snapshot.commit_id, guard.write_permission())?; + + let restore_commit_short = format!( + "{}{}", + &restore_commit_id.to_string()[..7].blue().underline(), + &restore_commit_id.to_string()[7..12].blue().dimmed() + ); + + println!( + "{} Undo completed successfully! New snapshot: {}", + "✓".green().bold(), + restore_commit_short + ); + + Ok(()) +}