Skip to content

Commit 4cf04ee

Browse files
committed
Port but commit but use but-api
1 parent fa790f0 commit 4cf04ee

File tree

3 files changed

+371
-0
lines changed

3 files changed

+371
-0
lines changed

crates/but/src/args.rs

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,17 @@ For examples see `but rub --help`."
6767
#[clap(long, short = 'd')]
6868
delete: bool,
6969
},
70+
/// Commit changes to a stack.
71+
Commit {
72+
/// Commit message
73+
#[clap(short = 'm', long = "message")]
74+
message: Option<String>,
75+
/// Branch CLI ID or name to derive the stack to commit to
76+
branch: Option<String>,
77+
/// Only commit assigned files, not unassigned files
78+
#[clap(short = 'o', long = "only")]
79+
only: bool,
80+
},
7081
/// Starts up the MCP server.
7182
Mcp {
7283
/// Starts the internal MCP server which has more granular tools.
@@ -102,6 +113,8 @@ pub enum CommandName {
102113
Stf,
103114
#[clap(alias = "rub")]
104115
Rub,
116+
#[clap(alias = "commit")]
117+
Commit,
105118
BaseCheck,
106119
BaseUpdate,
107120
BranchNew,

crates/but/src/commit/mod.rs

Lines changed: 342 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,342 @@
1+
use crate::status::assignment::FileAssignment;
2+
use bstr::{BString, ByteSlice};
3+
use but_api::{commands::diff, commands::workspace, hex_hash::HexHash};
4+
use but_core::ui::TreeChange;
5+
use but_hunk_assignment::HunkAssignment;
6+
use but_settings::AppSettings;
7+
use but_workspace::DiffSpec;
8+
use gitbutler_command_context::CommandContext;
9+
use gitbutler_project::Project;
10+
use std::collections::BTreeMap;
11+
use std::io::{self, Write};
12+
use std::path::Path;
13+
14+
pub(crate) fn commit(
15+
repo_path: &Path,
16+
_json: bool,
17+
message: Option<&str>,
18+
branch_hint: Option<&str>,
19+
only: bool,
20+
) -> anyhow::Result<()> {
21+
let project = Project::find_by_path(repo_path)?;
22+
let mut ctx = CommandContext::open(&project, AppSettings::load_from_default_path_creating()?)?;
23+
24+
// Get all stacks using but-api
25+
let project_id = project.id;
26+
let stack_entries = workspace::stacks(project_id, None)?;
27+
let stacks: Vec<(but_workspace::StackId, but_workspace::ui::StackDetails)> = stack_entries
28+
.iter()
29+
.filter_map(|s| {
30+
s.id.and_then(|id| {
31+
workspace::stack_details(project_id, Some(id))
32+
.ok()
33+
.map(|details| (id, details))
34+
})
35+
})
36+
.collect();
37+
38+
// Determine which stack to commit to
39+
let target_stack_id = if stacks.is_empty() {
40+
anyhow::bail!("No stacks found. Create a stack first with 'but branch new <name>'.");
41+
} else if stacks.len() == 1 {
42+
// Only one stack, use it
43+
stacks[0].0
44+
} else {
45+
// Multiple stacks - need to select one
46+
select_stack(&mut ctx, &stacks, branch_hint)?
47+
};
48+
49+
// Get changes and assignments using but-api
50+
let worktree_changes = diff::changes_in_worktree(project_id)?;
51+
let changes = worktree_changes.worktree_changes.changes;
52+
let assignments = worktree_changes.assignments;
53+
54+
// Group assignments by file
55+
let mut by_file: BTreeMap<BString, Vec<HunkAssignment>> = BTreeMap::new();
56+
for assignment in &assignments {
57+
by_file
58+
.entry(assignment.path_bytes.clone())
59+
.or_default()
60+
.push(assignment.clone());
61+
}
62+
63+
let mut assignments_by_file: BTreeMap<BString, FileAssignment> = BTreeMap::new();
64+
for (path, assignments) in &by_file {
65+
assignments_by_file.insert(
66+
path.clone(),
67+
FileAssignment::from_assignments(path, assignments),
68+
);
69+
}
70+
71+
// Get files to commit: unassigned files + files assigned to target stack
72+
let mut files_to_commit = Vec::new();
73+
74+
if !only {
75+
// Add unassigned files (unless --only flag is used)
76+
let unassigned =
77+
crate::status::assignment::filter_by_stack_id(assignments_by_file.values(), &None);
78+
files_to_commit.extend(unassigned);
79+
}
80+
81+
// Add files assigned to target stack
82+
let stack_assigned = crate::status::assignment::filter_by_stack_id(
83+
assignments_by_file.values(),
84+
&Some(target_stack_id),
85+
);
86+
files_to_commit.extend(stack_assigned);
87+
88+
if files_to_commit.is_empty() {
89+
println!("No changes to commit.");
90+
return Ok(());
91+
}
92+
93+
// Get commit message
94+
let commit_message = if let Some(msg) = message {
95+
msg.to_string()
96+
} else {
97+
get_commit_message_from_editor(&files_to_commit, &changes)?
98+
};
99+
100+
if commit_message.trim().is_empty() {
101+
anyhow::bail!("Aborting commit due to empty commit message.");
102+
}
103+
104+
// Find the target stack and determine the target branch
105+
let target_stack = &stacks
106+
.iter()
107+
.find(|(id, _)| *id == target_stack_id)
108+
.unwrap()
109+
.1;
110+
111+
// If a branch hint was provided, find that specific branch; otherwise use first branch
112+
let target_branch = if let Some(hint) = branch_hint {
113+
// First try exact name match
114+
target_stack
115+
.branch_details
116+
.iter()
117+
.find(|branch| branch.name == hint)
118+
.or_else(|| {
119+
// If no exact match, try to parse as CLI ID and match
120+
if let Ok(cli_ids) = crate::id::CliId::from_str(&mut ctx, hint) {
121+
for cli_id in cli_ids {
122+
if let crate::id::CliId::Branch { name } = cli_id
123+
&& let Some(branch) =
124+
target_stack.branch_details.iter().find(|b| b.name == name)
125+
{
126+
return Some(branch);
127+
}
128+
}
129+
}
130+
None
131+
})
132+
.ok_or_else(|| anyhow::anyhow!("Branch '{}' not found in target stack", hint))?
133+
} else {
134+
// No branch hint, use first branch (HEAD of stack)
135+
target_stack
136+
.branch_details
137+
.first()
138+
.ok_or_else(|| anyhow::anyhow!("No branches found in target stack"))?
139+
};
140+
141+
// Convert files to DiffSpec
142+
let diff_specs: Vec<DiffSpec> = files_to_commit
143+
.iter()
144+
.map(|fa| {
145+
// Collect hunk headers from all assignments for this file
146+
let hunk_headers: Vec<but_workspace::HunkHeader> = fa
147+
.assignments
148+
.iter()
149+
.filter_map(|assignment| assignment.hunk_header)
150+
.collect();
151+
152+
DiffSpec {
153+
previous_path: None,
154+
path: fa.path.clone(),
155+
hunk_headers,
156+
}
157+
})
158+
.collect();
159+
160+
// Get the HEAD commit of the target branch to use as parent (preserves stacking)
161+
let parent_commit_id = target_branch.tip;
162+
163+
// Use but-api to create the commit
164+
let outcome = workspace::create_commit_from_worktree_changes(
165+
project_id,
166+
target_stack_id,
167+
Some(HexHash::from(parent_commit_id)),
168+
diff_specs,
169+
commit_message,
170+
target_branch.name.to_string(),
171+
)?;
172+
173+
let commit_short = match outcome.new_commit {
174+
Some(id) => id.to_string()[..7].to_string(),
175+
None => "unknown".to_string(),
176+
};
177+
println!(
178+
"Created commit {} on branch {}",
179+
commit_short, target_branch.name
180+
);
181+
182+
Ok(())
183+
}
184+
185+
fn select_stack(
186+
ctx: &mut CommandContext,
187+
stacks: &[(but_workspace::StackId, but_workspace::ui::StackDetails)],
188+
branch_hint: Option<&str>,
189+
) -> anyhow::Result<but_workspace::StackId> {
190+
// If a branch hint is provided, try to find it
191+
if let Some(hint) = branch_hint {
192+
// First, try to find by exact branch name match
193+
for (stack_id, stack_details) in stacks {
194+
for branch in &stack_details.branch_details {
195+
if branch.name == hint {
196+
return Ok(*stack_id);
197+
}
198+
}
199+
}
200+
201+
// If no exact match, try to parse as CLI ID
202+
match crate::id::CliId::from_str(ctx, hint) {
203+
Ok(cli_ids) => {
204+
// Filter for branch CLI IDs and find corresponding stack
205+
for cli_id in cli_ids {
206+
if let crate::id::CliId::Branch { name } = cli_id {
207+
for (stack_id, stack_details) in stacks {
208+
for branch in &stack_details.branch_details {
209+
if branch.name == name {
210+
return Ok(*stack_id);
211+
}
212+
}
213+
}
214+
}
215+
}
216+
}
217+
Err(_) => {
218+
// Ignore CLI ID parsing errors and continue with other methods
219+
}
220+
}
221+
222+
anyhow::bail!("Branch '{}' not found", hint);
223+
}
224+
225+
// No hint provided, show options and prompt
226+
println!("Multiple stacks found. Choose one to commit to:");
227+
for (i, (stack_id, stack_details)) in stacks.iter().enumerate() {
228+
let branch_names: Vec<String> = stack_details
229+
.branch_details
230+
.iter()
231+
.map(|b| b.name.to_string())
232+
.collect();
233+
println!(" {}. {} [{}]", i + 1, stack_id, branch_names.join(", "));
234+
}
235+
236+
print!("Enter selection (1-{}): ", stacks.len());
237+
io::stdout().flush()?;
238+
239+
let mut input = String::new();
240+
io::stdin().read_line(&mut input)?;
241+
242+
let selection: usize = input
243+
.trim()
244+
.parse()
245+
.map_err(|_| anyhow::anyhow!("Invalid selection"))?;
246+
247+
if selection < 1 || selection > stacks.len() {
248+
anyhow::bail!("Selection out of range");
249+
}
250+
251+
Ok(stacks[selection - 1].0)
252+
}
253+
254+
fn get_commit_message_from_editor(
255+
files_to_commit: &[FileAssignment],
256+
changes: &[TreeChange],
257+
) -> anyhow::Result<String> {
258+
// Get editor command
259+
let editor = get_editor_command()?;
260+
261+
// Create temporary file with template
262+
let temp_dir = std::env::temp_dir();
263+
let temp_file = temp_dir.join(format!("but_commit_msg_{}", std::process::id()));
264+
265+
// Generate commit message template
266+
let mut template = String::new();
267+
template.push_str("\n# Please enter the commit message for your changes. Lines starting\n");
268+
template.push_str("# with '#' will be ignored, and an empty message aborts the commit.\n");
269+
template.push_str("#\n");
270+
template.push_str("# Changes to be committed:\n");
271+
272+
for fa in files_to_commit {
273+
let status_char = get_status_char(&fa.path, changes);
274+
template.push_str(&format!("#\t{} {}\n", status_char, fa.path.to_str_lossy()));
275+
}
276+
template.push_str("#\n");
277+
278+
std::fs::write(&temp_file, template)?;
279+
280+
// Launch editor
281+
let status = std::process::Command::new(&editor)
282+
.arg(&temp_file)
283+
.status()?;
284+
285+
if !status.success() {
286+
anyhow::bail!("Editor exited with non-zero status");
287+
}
288+
289+
// Read the result and strip comments
290+
let content = std::fs::read_to_string(&temp_file)?;
291+
std::fs::remove_file(&temp_file).ok(); // Best effort cleanup
292+
293+
let message = content
294+
.lines()
295+
.filter(|line| !line.starts_with('#'))
296+
.collect::<Vec<_>>()
297+
.join("\n")
298+
.trim()
299+
.to_string();
300+
301+
Ok(message)
302+
}
303+
304+
fn get_editor_command() -> anyhow::Result<String> {
305+
// Try $EDITOR first
306+
if let Ok(editor) = std::env::var("EDITOR") {
307+
return Ok(editor);
308+
}
309+
310+
// Try git config core.editor
311+
if let Ok(output) = std::process::Command::new("git")
312+
.args(["config", "--get", "core.editor"])
313+
.output()
314+
&& output.status.success()
315+
{
316+
let editor = String::from_utf8_lossy(&output.stdout).trim().to_string();
317+
if !editor.is_empty() {
318+
return Ok(editor);
319+
}
320+
}
321+
322+
// Fallback to platform defaults
323+
#[cfg(windows)]
324+
return Ok("notepad".to_string());
325+
326+
#[cfg(not(windows))]
327+
return Ok("vi".to_string());
328+
}
329+
330+
fn get_status_char(path: &BString, changes: &[TreeChange]) -> &'static str {
331+
for change in changes {
332+
if change.path_bytes == *path {
333+
return match change.status {
334+
but_core::ui::TreeStatus::Addition { .. } => "new file:",
335+
but_core::ui::TreeStatus::Modification { .. } => "modified:",
336+
but_core::ui::TreeStatus::Deletion { .. } => "deleted:",
337+
but_core::ui::TreeStatus::Rename { .. } => "renamed:",
338+
};
339+
}
340+
}
341+
"modified:" // fallback
342+
}

crates/but/src/main.rs

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ use but_claude::hooks::OutputAsJson;
99
mod base;
1010
mod branch;
1111
mod command;
12+
mod commit;
1213
mod id;
1314
mod init;
1415
mod log;
@@ -150,6 +151,21 @@ async fn main() -> Result<()> {
150151
metrics_if_configured(app_settings, CommandName::Rub, props(start, &result)).ok();
151152
Ok(())
152153
}
154+
Subcommands::Commit {
155+
message,
156+
branch,
157+
only,
158+
} => {
159+
let result = commit::commit(
160+
&args.current_dir,
161+
args.json,
162+
message.as_deref(),
163+
branch.as_deref(),
164+
*only,
165+
);
166+
metrics_if_configured(app_settings, CommandName::Commit, props(start, &result)).ok();
167+
result
168+
}
153169
Subcommands::Init { repo } => init::repo(&args.current_dir, args.json, *repo)
154170
.context("Failed to initialize GitButler project."),
155171
}

0 commit comments

Comments
 (0)