Skip to content

Commit 726653e

Browse files
committed
Implement but describe
1 parent 30a369d commit 726653e

File tree

3 files changed

+247
-0
lines changed

3 files changed

+247
-0
lines changed

crates/but/src/args.rs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,12 @@ For examples see `but rub --help`."
7878
#[clap(short = 'o', long = "only")]
7979
only: bool,
8080
},
81+
/// Edit the commit message of the specified commit.
82+
#[clap(alias = "desc")]
83+
Describe {
84+
/// Commit ID to edit the message for
85+
commit: String,
86+
},
8187
/// Starts up the MCP server.
8288
Mcp {
8389
/// Starts the internal MCP server which has more granular tools.
@@ -115,6 +121,8 @@ pub enum CommandName {
115121
Rub,
116122
#[clap(alias = "commit")]
117123
Commit,
124+
#[clap(alias = "describe")]
125+
Describe,
118126
BaseCheck,
119127
BaseUpdate,
120128
BranchNew,

crates/but/src/describe/mod.rs

Lines changed: 233 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,233 @@
1+
use crate::id::CliId;
2+
use anyhow::Result;
3+
use but_settings::AppSettings;
4+
use gitbutler_command_context::CommandContext;
5+
use gitbutler_oxidize::ObjectIdExt;
6+
use gitbutler_project::Project;
7+
use std::path::Path;
8+
9+
pub(crate) fn edit_commit_message(
10+
repo_path: &Path,
11+
_json: bool,
12+
commit_target: &str,
13+
) -> Result<()> {
14+
let project = Project::find_by_path(repo_path)?;
15+
let mut ctx = CommandContext::open(&project, AppSettings::load_from_default_path_creating()?)?;
16+
17+
// Resolve the commit ID
18+
let cli_ids = CliId::from_str(&mut ctx, commit_target)?;
19+
20+
if cli_ids.is_empty() {
21+
anyhow::bail!("Commit '{}' not found", commit_target);
22+
}
23+
24+
if cli_ids.len() > 1 {
25+
anyhow::bail!(
26+
"Commit '{}' is ambiguous. Found {} matches",
27+
commit_target,
28+
cli_ids.len()
29+
);
30+
}
31+
32+
let cli_id = &cli_ids[0];
33+
34+
match cli_id {
35+
CliId::Commit { oid } => {
36+
edit_commit_message_by_id(&ctx, &project, *oid)?;
37+
}
38+
_ => {
39+
anyhow::bail!("Target must be a commit ID, not {}", cli_id.kind());
40+
}
41+
}
42+
43+
Ok(())
44+
}
45+
46+
fn edit_commit_message_by_id(
47+
ctx: &CommandContext,
48+
project: &Project,
49+
commit_oid: gix::ObjectId,
50+
) -> Result<()> {
51+
// Find which stack this commit belongs to
52+
let stacks = but_api::workspace::stacks(project.id, None)?;
53+
let mut found_commit_message = None;
54+
let mut stack_id = None;
55+
56+
for stack_entry in &stacks {
57+
if let Some(sid) = stack_entry.id {
58+
let stack_details = but_api::workspace::stack_details(project.id, Some(sid))?;
59+
60+
// Check if this commit exists in any branch of this stack
61+
for branch_details in &stack_details.branch_details {
62+
// Check local commits
63+
for commit in &branch_details.commits {
64+
if commit.id == commit_oid {
65+
found_commit_message = Some(commit.message.clone());
66+
stack_id = Some(sid);
67+
break;
68+
}
69+
}
70+
71+
// Also check upstream commits
72+
if found_commit_message.is_none() {
73+
for commit in &branch_details.upstream_commits {
74+
if commit.id == commit_oid {
75+
found_commit_message = Some(commit.message.clone());
76+
stack_id = Some(sid);
77+
break;
78+
}
79+
}
80+
}
81+
82+
if found_commit_message.is_some() {
83+
break;
84+
}
85+
}
86+
if found_commit_message.is_some() {
87+
break;
88+
}
89+
}
90+
}
91+
92+
let commit_message = found_commit_message
93+
.ok_or_else(|| anyhow::anyhow!("Commit {} not found in any stack", commit_oid))?;
94+
95+
let stack_id = stack_id
96+
.ok_or_else(|| anyhow::anyhow!("Could not find stack for commit {}", commit_oid))?;
97+
98+
// Get the files changed in this commit using but_api
99+
let commit_details = but_api::diff::commit_details(project.id, commit_oid.into())?;
100+
let changed_files = get_changed_files_from_commit_details(&commit_details);
101+
102+
// Get current commit message
103+
let current_message = commit_message.to_string();
104+
105+
// Open editor with current message and file list
106+
let new_message = get_commit_message_from_editor(&current_message, &changed_files)?;
107+
108+
if new_message.trim() == current_message.trim() {
109+
println!("No changes to commit message.");
110+
return Ok(());
111+
}
112+
113+
// Use gitbutler_branch_actions::update_commit_message instead of low-level primitives
114+
let git2_commit_oid = commit_oid.to_git2();
115+
let new_commit_oid = gitbutler_branch_actions::update_commit_message(
116+
ctx,
117+
stack_id,
118+
git2_commit_oid,
119+
&new_message,
120+
)?;
121+
122+
println!(
123+
"Updated commit message for {} (now {})",
124+
&commit_oid.to_string()[..7],
125+
&new_commit_oid.to_string()[..7]
126+
);
127+
128+
Ok(())
129+
}
130+
131+
fn get_changed_files_from_commit_details(
132+
commit_details: &but_api::diff::CommitDetails,
133+
) -> Vec<String> {
134+
let mut files = Vec::new();
135+
136+
for change in &commit_details.changes.changes {
137+
let status = match &change.status {
138+
but_core::ui::TreeStatus::Addition { .. } => "new file:",
139+
but_core::ui::TreeStatus::Deletion { .. } => "deleted:",
140+
but_core::ui::TreeStatus::Modification { .. } => "modified:",
141+
but_core::ui::TreeStatus::Rename { .. } => "modified:",
142+
};
143+
144+
let file_path = change.path.to_string();
145+
files.push(format!("{status} {file_path}"));
146+
}
147+
148+
files.sort();
149+
files
150+
}
151+
152+
fn get_commit_message_from_editor(
153+
current_message: &str,
154+
changed_files: &[String],
155+
) -> Result<String> {
156+
// Get editor command
157+
let editor = get_editor_command()?;
158+
159+
// Create temporary file with current message and file list
160+
let temp_dir = std::env::temp_dir();
161+
let temp_file = temp_dir.join(format!("but_commit_msg_{}", std::process::id()));
162+
163+
// Generate commit message template with current message
164+
let mut template = String::new();
165+
template.push_str(current_message);
166+
if !current_message.is_empty() && !current_message.ends_with('\n') {
167+
template.push('\n');
168+
}
169+
template.push_str("\n# Please enter the commit message for your changes. Lines starting\n");
170+
template.push_str("# with '#' will be ignored, and an empty message aborts the commit.\n");
171+
template.push_str("#\n");
172+
template.push_str("# Changes in this commit:\n");
173+
174+
for file in changed_files {
175+
template.push_str(&format!("#\t{file}\n"));
176+
}
177+
template.push_str("#\n");
178+
179+
std::fs::write(&temp_file, template)?;
180+
181+
// Launch editor
182+
let status = std::process::Command::new(&editor)
183+
.arg(&temp_file)
184+
.status()?;
185+
186+
if !status.success() {
187+
anyhow::bail!("Editor exited with non-zero status");
188+
}
189+
190+
// Read the result and strip comments
191+
let content = std::fs::read_to_string(&temp_file)?;
192+
std::fs::remove_file(&temp_file).ok(); // Best effort cleanup
193+
194+
let message = content
195+
.lines()
196+
.filter(|line| !line.starts_with('#'))
197+
.collect::<Vec<_>>()
198+
.join("\n")
199+
.trim()
200+
.to_string();
201+
202+
if message.is_empty() {
203+
anyhow::bail!("Aborting due to empty commit message");
204+
}
205+
206+
Ok(message)
207+
}
208+
209+
fn get_editor_command() -> Result<String> {
210+
// Try $EDITOR first
211+
if let Ok(editor) = std::env::var("EDITOR") {
212+
return Ok(editor);
213+
}
214+
215+
// Try git config core.editor
216+
if let Ok(output) = std::process::Command::new("git")
217+
.args(["config", "--get", "core.editor"])
218+
.output()
219+
&& output.status.success()
220+
{
221+
let editor = String::from_utf8_lossy(&output.stdout).trim().to_string();
222+
if !editor.is_empty() {
223+
return Ok(editor);
224+
}
225+
}
226+
227+
// Fallback to platform defaults
228+
#[cfg(windows)]
229+
return Ok("notepad".to_string());
230+
231+
#[cfg(not(windows))]
232+
return Ok("vi".to_string());
233+
}

crates/but/src/main.rs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ mod base;
1010
mod branch;
1111
mod command;
1212
mod commit;
13+
mod describe;
1314
mod id;
1415
mod init;
1516
mod log;
@@ -166,6 +167,11 @@ async fn main() -> Result<()> {
166167
metrics_if_configured(app_settings, CommandName::Commit, props(start, &result)).ok();
167168
result
168169
}
170+
Subcommands::Describe { commit } => {
171+
let result = describe::edit_commit_message(&args.current_dir, args.json, commit);
172+
metrics_if_configured(app_settings, CommandName::Describe, props(start, &result)).ok();
173+
result
174+
}
169175
Subcommands::Init { repo } => init::repo(&args.current_dir, args.json, *repo)
170176
.context("Failed to initialize GitButler project."),
171177
}

0 commit comments

Comments
 (0)