Skip to content

Commit 7e34b94

Browse files
committed
✨ Add --amend flag to gen command for amending previous commits
Implement commit amending support via `git-iris gen --amend`, allowing users to regenerate and replace the previous commit with a new AI-generated message that considers both the original commit content and any newly staged changes. The implementation includes: - New TaskContext::Amend variant that carries the original commit message - amend_commit() function in git module that uses git2's commit amend API - perform_amend() in GitCommitService with hook execution support - CLI integration with validation (requires --print or --auto-commit for now) - Updated commit capability prompt with amend mode instructions When amending, Iris sees the combined diff from HEAD^1 to staged state, ensuring the regenerated message accurately describes the full amended commit.
1 parent f4bd00e commit 7e34b94

File tree

6 files changed

+277
-8
lines changed

6 files changed

+277
-8
lines changed

src/agents/capabilities/commit.toml

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ Do not skip this step.
1414
- `project_docs(doc_type="context")` - **CALL FIRST** - Get README + AGENTS.md/CLAUDE.md for project context
1515
- `git_diff()` - Get summary of staged changes with relevance scores (default: summary only)
1616
- `git_diff(detail="standard", files=["path1","path2"])` - Get full diffs for specific files
17+
- `git_diff(from="HEAD^1")` - Get combined diff from parent commit to staged (use for amend)
1718
- `git_log(count=5)` - Recent commits for style reference
1819
- `project_metadata()` - Get project language, framework, and dependencies (optional)
1920
- `parallel_analyze(tasks=[...])` - Spawn subagents for very large changesets (optional)
@@ -28,6 +29,15 @@ Do not skip this step.
2829
2930
**CRITICAL**: Never request all diffs at once for large changesets. Always start with summary, then selectively drill into important files.
3031
32+
## Amend Mode
33+
When the context indicates **amend mode** (you'll see `"mode": "amend"` with an `original_message`):
34+
- You are **replacing** an existing commit, not creating a new one
35+
- Use `git_diff(from="HEAD^1")` to see the **combined** diff (original commit + new staged changes)
36+
- The original commit message is provided for context—consider its intent
37+
- Generate a **new** message that accurately describes the full amended commit
38+
- Don't just append to the original message—write fresh based on the complete changeset
39+
- The amended commit should feel cohesive, as if it was always this way
40+
3141
## Context Strategy by Size
3242
- **Small** (≤3 files, <100 lines): Can use `git_diff(detail="standard")` to see all diffs
3343
- **Medium** (≤10 files, <500 lines): Start with summary, then get diffs for >60% relevance files

src/agents/context.rs

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,13 @@ pub enum TaskContext {
3333
to: String,
3434
},
3535

36+
/// Amend the previous commit with staged changes
37+
/// The agent sees the combined diff from HEAD^1 to staged state
38+
Amend {
39+
/// The original commit message being amended
40+
original_message: String,
41+
},
42+
3643
/// Let the agent discover context via tools (default for gen command)
3744
#[default]
3845
Discover,
@@ -47,6 +54,12 @@ impl TaskContext {
4754
}
4855
}
4956

57+
/// Create context for amending the previous commit.
58+
/// The agent will see the combined diff from HEAD^1 to staged state.
59+
pub fn for_amend(original_message: String) -> Self {
60+
Self::Amend { original_message }
61+
}
62+
5063
/// Create context for the review command with full parameter validation.
5164
///
5265
/// Validates:
@@ -140,6 +153,9 @@ impl TaskContext {
140153
Self::Range { from, to } => {
141154
format!("git_diff(from=\"{from}\", to=\"{to}\")")
142155
}
156+
Self::Amend { .. } => {
157+
"git_diff(from=\"HEAD^1\") for combined amend diff (original commit + new staged changes)".to_string()
158+
}
143159
Self::Discover => "git_diff() to discover current changes".to_string(),
144160
}
145161
}
@@ -158,6 +174,19 @@ impl TaskContext {
158174
}
159175
)
160176
}
177+
178+
/// Check if this is an amend operation
179+
pub fn is_amend(&self) -> bool {
180+
matches!(self, Self::Amend { .. })
181+
}
182+
183+
/// Get the original commit message if this is an amend context
184+
pub fn original_message(&self) -> Option<&str> {
185+
match self {
186+
Self::Amend { original_message } => Some(original_message),
187+
_ => None,
188+
}
189+
}
161190
}
162191

163192
impl std::fmt::Display for TaskContext {
@@ -172,6 +201,7 @@ impl std::fmt::Display for TaskContext {
172201
}
173202
Self::Commit { commit_id } => write!(f, "commit {commit_id}"),
174203
Self::Range { from, to } => write!(f, "changes from {from} to {to}"),
204+
Self::Amend { .. } => write!(f, "amending previous commit"),
175205
Self::Discover => write!(f, "auto-discovered changes"),
176206
}
177207
}
@@ -308,5 +338,23 @@ mod tests {
308338
};
309339
assert!(range.diff_hint().contains("main"));
310340
assert!(range.diff_hint().contains("dev"));
341+
342+
let amend = TaskContext::for_amend("Fix bug".to_string());
343+
assert!(amend.diff_hint().contains("HEAD^1"));
344+
}
345+
346+
#[test]
347+
fn test_amend_context() {
348+
let ctx = TaskContext::for_amend("Initial commit message".to_string());
349+
assert!(ctx.is_amend());
350+
assert_eq!(ctx.original_message(), Some("Initial commit message"));
351+
assert!(!ctx.is_range());
352+
assert!(!ctx.includes_unstaged());
353+
}
354+
355+
#[test]
356+
fn test_amend_display() {
357+
let ctx = TaskContext::for_amend("Fix bug".to_string());
358+
assert_eq!(format!("{ctx}"), "amending previous commit");
311359
}
312360
}

src/cli.rs

Lines changed: 45 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,10 @@ pub enum Commands {
120120
/// Skip the verification step (pre/post commit hooks)
121121
#[arg(long, help = "Skip verification steps (pre/post commit hooks)")]
122122
no_verify: bool,
123+
124+
/// Amend the previous commit instead of creating a new one
125+
#[arg(long, help = "Amend the previous commit with staged changes")]
126+
amend: bool,
123127
},
124128

125129
/// Review staged changes and provide feedback
@@ -479,6 +483,7 @@ struct GenConfig {
479483
use_gitmoji: bool,
480484
print_only: bool,
481485
verify: bool,
486+
amend: bool,
482487
}
483488

484489
/// Handle the `Gen` command with agent framework and Studio integration
@@ -507,6 +512,13 @@ async fn handle_gen_with_agent(
507512
ui::print_info("Run 'git-iris list-presets' to see available presets for commits.");
508513
}
509514

515+
// Amend mode requires --print or --auto-commit (Studio amend support coming later)
516+
if config.amend && !config.print_only && !config.auto_commit {
517+
ui::print_warning("--amend requires --print or --auto-commit for now.");
518+
ui::print_info("Example: git-iris gen --amend --auto-commit");
519+
return Ok(());
520+
}
521+
510522
let mut cfg = Config::load()?;
511523
common.apply_to_config(&mut cfg)?;
512524

@@ -533,7 +545,9 @@ async fn handle_gen_with_agent(
533545

534546
// For --print or --auto-commit, we need to generate the message first
535547
if config.print_only || config.auto_commit {
536-
if git_info.staged_files.is_empty() {
548+
// For amend mode, we allow empty staged changes (amending message only)
549+
// For regular commits, we require staged changes
550+
if git_info.staged_files.is_empty() && !config.amend {
537551
ui::print_warning(
538552
"No staged changes. Please stage your changes before generating a commit message.",
539553
);
@@ -548,10 +562,23 @@ async fn handle_gen_with_agent(
548562
}
549563

550564
// Create spinner for agent mode
551-
let spinner = ui::create_spinner("Generating commit message...");
565+
let spinner_msg = if config.amend {
566+
"Generating amended commit message..."
567+
} else {
568+
"Generating commit message..."
569+
};
570+
let spinner = ui::create_spinner(spinner_msg);
552571

553572
// Use IrisAgentService for commit message generation
554-
let context = TaskContext::for_gen();
573+
// For amend, we pass the original message as context
574+
let context = if config.amend {
575+
let original_message = commit_service
576+
.get_head_commit_message()
577+
.unwrap_or_default();
578+
TaskContext::for_amend(original_message)
579+
} else {
580+
TaskContext::for_gen()
581+
};
555582
let response = agent_service.execute_task("commit", context).await?;
556583

557584
// Extract commit message from response
@@ -567,7 +594,7 @@ async fn handle_gen_with_agent(
567594
return Ok(());
568595
}
569596

570-
// Auto-commit mode
597+
// Auto-commit/amend mode
571598
if commit_service.is_remote() {
572599
ui::print_error(
573600
"Cannot automatically commit to a remote repository. Use --print instead.",
@@ -577,14 +604,21 @@ async fn handle_gen_with_agent(
577604
));
578605
}
579606

580-
match commit_service.perform_commit(&format_commit_message(&generated_message)) {
607+
let commit_result = if config.amend {
608+
commit_service.perform_amend(&format_commit_message(&generated_message))
609+
} else {
610+
commit_service.perform_commit(&format_commit_message(&generated_message))
611+
};
612+
613+
match commit_result {
581614
Ok(result) => {
582615
let output =
583616
format_commit_result(&result, &format_commit_message(&generated_message));
584617
println!("{output}");
585618
}
586619
Err(e) => {
587-
eprintln!("Failed to commit: {e}");
620+
let action = if config.amend { "amend" } else { "commit" };
621+
eprintln!("Failed to {action}: {e}");
588622
return Err(e);
589623
}
590624
}
@@ -618,12 +652,13 @@ async fn handle_gen(
618652
repository_url: Option<String>,
619653
) -> anyhow::Result<()> {
620654
log_debug!(
621-
"Handling 'gen' command with common: {:?}, auto_commit: {}, use_gitmoji: {}, print: {}, verify: {}",
655+
"Handling 'gen' command with common: {:?}, auto_commit: {}, use_gitmoji: {}, print: {}, verify: {}, amend: {}",
622656
common,
623657
config.auto_commit,
624658
config.use_gitmoji,
625659
config.print_only,
626-
config.verify
660+
config.verify,
661+
config.amend
627662
);
628663

629664
ui::print_version(crate_version!());
@@ -878,6 +913,7 @@ pub async fn handle_command(
878913
no_gitmoji,
879914
print,
880915
no_verify,
916+
amend,
881917
} => {
882918
handle_gen(
883919
common,
@@ -886,6 +922,7 @@ pub async fn handle_command(
886922
use_gitmoji: !no_gitmoji,
887923
print_only: print,
888924
verify: !no_verify,
925+
amend,
889926
},
890927
repository_url,
891928
)

src/git/commit.rs

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,106 @@ pub fn commit(repo: &Repository, message: &str, is_remote: bool) -> Result<Commi
101101
})
102102
}
103103

104+
/// Amends the previous commit with staged changes and a new message.
105+
///
106+
/// This replaces HEAD with a new commit that has:
107+
/// - HEAD's parent as its parent
108+
/// - The current staged index as its tree
109+
/// - The new message provided
110+
///
111+
/// # Arguments
112+
///
113+
/// * `repo` - The git repository
114+
/// * `message` - The new commit message
115+
/// * `is_remote` - Whether the repository is remote
116+
///
117+
/// # Returns
118+
///
119+
/// A Result containing the `CommitResult` or an error.
120+
pub fn amend_commit(repo: &Repository, message: &str, is_remote: bool) -> Result<CommitResult> {
121+
if is_remote {
122+
return Err(anyhow!(
123+
"Cannot amend a commit in a remote repository in read-only mode"
124+
));
125+
}
126+
127+
let signature = repo.signature()?;
128+
let mut index = repo.index()?;
129+
let tree_id = index.write_tree()?;
130+
let tree = repo.find_tree(tree_id)?;
131+
132+
// Get the current HEAD commit (the one we're amending)
133+
let head_commit = repo.head()?.peel_to_commit()?;
134+
135+
// Amend the HEAD commit with the new tree and message
136+
let commit_oid = head_commit.amend(
137+
Some("HEAD"), // Update the HEAD reference
138+
Some(&signature), // New author (use current)
139+
Some(&signature), // New committer (use current)
140+
None, // Keep original encoding
141+
Some(message), // New message
142+
Some(&tree), // New tree (includes staged changes)
143+
)?;
144+
145+
let branch_name = repo.head()?.shorthand().unwrap_or("HEAD").to_string();
146+
let commit = repo.find_commit(commit_oid)?;
147+
let short_hash = commit.id().to_string()[..7].to_string();
148+
149+
// Calculate diff stats from the original parent to the new tree
150+
let mut files_changed = 0;
151+
let mut insertions = 0;
152+
let mut deletions = 0;
153+
let new_files = Vec::new();
154+
155+
// Use the first parent for diff (or empty tree if initial commit)
156+
let parent_tree = if head_commit.parent_count() > 0 {
157+
Some(head_commit.parent(0)?.tree()?)
158+
} else {
159+
None
160+
};
161+
let diff = repo.diff_tree_to_tree(parent_tree.as_ref(), Some(&tree), None)?;
162+
163+
diff.print(git2::DiffFormat::NameStatus, |_, _, line| {
164+
files_changed += 1;
165+
if line.origin() == '+' {
166+
insertions += 1;
167+
} else if line.origin() == '-' {
168+
deletions += 1;
169+
}
170+
true
171+
})?;
172+
173+
log_debug!(
174+
"Amended commit {} -> {} with {} files changed",
175+
&head_commit.id().to_string()[..7],
176+
short_hash,
177+
files_changed
178+
);
179+
180+
Ok(CommitResult {
181+
branch: branch_name,
182+
commit_hash: short_hash,
183+
files_changed,
184+
insertions,
185+
deletions,
186+
new_files,
187+
})
188+
}
189+
190+
/// Gets the message of the HEAD commit.
191+
///
192+
/// # Arguments
193+
///
194+
/// * `repo` - The git repository
195+
///
196+
/// # Returns
197+
///
198+
/// A Result containing the commit message or an error.
199+
pub fn get_head_commit_message(repo: &Repository) -> Result<String> {
200+
let head_commit = repo.head()?.peel_to_commit()?;
201+
Ok(head_commit.message().unwrap_or_default().to_string())
202+
}
203+
104204
/// Retrieves commits between two Git references.
105205
///
106206
/// # Arguments

src/git/repository.rs

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -748,6 +748,18 @@ impl GitRepo {
748748
commit::commit(&repo, message, self.is_remote)
749749
}
750750

751+
/// Amend the previous commit with staged changes and a new message
752+
pub fn amend_commit(&self, message: &str) -> Result<CommitResult> {
753+
let repo = self.open_repo()?;
754+
commit::amend_commit(&repo, message, self.is_remote)
755+
}
756+
757+
/// Get the message of the HEAD commit
758+
pub fn get_head_commit_message(&self) -> Result<String> {
759+
let repo = self.open_repo()?;
760+
commit::get_head_commit_message(&repo)
761+
}
762+
751763
/// Check if inside a working tree
752764
pub fn is_inside_work_tree() -> Result<bool> {
753765
is_inside_work_tree()

0 commit comments

Comments
 (0)