1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
use std::path::Path;
use anyhow::{Context, Result};
use git2::Repository;
use serde::{Deserialize, Serialize};
use crate::branch;
use crate::core::repo::{self, Target};
use crate::core::agent_mode;
use crate::core::changeid;
use crate::core::msg;
use crate::core::transaction::{self, LoomState, Rollback};
use crate::core::weave;
use crate::git;
/// Resume context for a `reword` paused by a conflict.
#[derive(Serialize, Deserialize)]
struct RewordContext {
/// Short hash of the reworded commit, as it was before the rebase.
display: String,
/// Hash the reworded commit now has; the alias reads state files that
/// store it abbreviated, which names the commit just as well.
#[serde(alias = "new_display")]
new_hash: String,
}
/// Reword a commit message or rename a branch.
pub fn run(target: String, message: Option<String>) -> Result<()> {
let repo = repo::open_repo()?;
let resolved = repo::resolve_arg(
&repo,
&target,
&[repo::TargetKind::Branch, repo::TargetKind::Commit],
)?;
match resolved {
Target::Commit(hash) => reword_commit(&repo, &hash, message),
Target::Branch(name) => {
let new_name = match message {
Some(msg) => msg,
None => {
// Prompt for new branch name with current name as placeholder
msg::input_with_placeholder(
"New branch name",
&name,
"re-run with: loom reword <target> -m <new-name>",
|s| {
if s.trim().is_empty() {
Err("Branch name cannot be empty")
} else {
Ok(())
}
},
)?
}
};
let new_name = new_name.trim().to_string();
if new_name == name {
return Ok(());
}
let workdir = repo::require_workdir(&repo, "reword")?;
git::branch_validate_name(workdir, &new_name)?;
reword_branch(&repo, &name, &new_name)
}
_ => unreachable!(),
}
}
/// Reword a commit message using Weave-based interactive rebase.
///
/// Approach:
/// 1. Build todo (via Weave or linear walk), mark target as `edit`
/// 2. Run rebase (pauses at the target commit)
/// 3. git commit --allow-empty --amend --only [-m "message"]
/// 4. git rebase --continue
///
/// Step 4 can conflict: rewriting the target changes the SHAs above it, so any
/// merge commit in the way has to be rebuilt, and a merge that was resolved by
/// hand conflicts again. That is resumable work, so the reword pauses for
/// `loom continue` rather than throwing the amend away.
pub fn reword_commit(repo: &Repository, commit_hash: &str, message: Option<String>) -> Result<()> {
// Without -m the amend would open $GIT_EDITOR, which hangs a headless agent.
if agent_mode::enabled() && message.is_none() {
return Err(agent_mode::respond_needs_input(
agent_mode::InputKind::Text,
"Commit message",
vec![],
false,
"re-run with: loom reword <target> -m <message>",
));
}
let workdir = repo::require_workdir(repo, "reword")?;
let commit = repo.revparse_single(commit_hash)?.peel_to_commit()?;
let commit_oid = commit.id();
// The identity survives the reword: re-stamped on the new message, or
// restored after an editor that dropped it (Spec 002).
let keep = commit.message().ok().and_then(changeid::from_message);
let message = match &message {
Some(m) => Some(changeid::for_message(repo, workdir, m, keep.as_deref())?),
None => None,
};
// Restored whichever way the rebase ends (Spec 014).
let saved_staged = git::diff_cached(workdir)?;
// Step 1: Start interactive rebase with edit at target
// No state file exists yet, so a rebase still running after a failed abort
// leaves nobody to put the staging back: it is parked instead.
weave::start_edit_rebase(repo, workdir, commit_oid)
.inspect_err(|e| git::restore_or_park_after_abort(workdir, &saved_staged, e))?;
// Step 2: Amend the commit message
let amend = git::commit_amend(workdir, message.as_deref()).and_then(|()| match message {
Some(_) => Ok(()),
None => changeid::ensure_on_head(repo, workdir, keep.as_deref()),
});
if let Err(e) = amend {
// Outside the cleanup closure, for the reason given on step 1: a failed
// abort skips it, and there is still no state file.
let e = git::rebase_abort_then_cleanup(workdir, e, || {});
git::restore_or_park_after_abort(workdir, &saved_staged, &e);
return Err(e);
}
// Capture the new hash right after amending (before rebase --continue moves HEAD)
let new_hash = repo.head()?.peel_to_commit()?.id().to_string();
// Step 3: Save resume state, then continue the rebase.
let ctx = RewordContext {
display: git::short_hash(commit_hash).to_string(),
new_hash,
};
let git_dir = repo.path().to_path_buf();
transaction::save(
&git_dir,
&LoomState {
command: "reword".to_string(),
// Nothing else to undo: `git rebase --abort` discards the amend
// along with the rebase, and reword creates no commits or branches
// of its own.
rollback: Rollback {
saved_staged_patch: saved_staged.clone(),
..Default::default()
},
context: serde_json::to_value(&ctx)?,
protect: Vec::new(),
targets: Vec::new(),
},
)?;
// The rebase runs with `--empty=stop`, so a commit above the target whose
// changes are already in the base halts it: drop it and carry on, the way
// `--empty=drop` did, rather than report it as a conflict.
let outcome = git::carry_past_known_stops(
workdir,
&git_dir,
git::Protected::default(),
None,
git::continue_rebase(workdir)?,
)
.inspect_err(|_| {
// The refusal aborted the rebase itself, so there is none to abort
// here — but that abort can fail, and then the state file and the
// index are both still `loom abort`'s to deal with.
if git::rebase_is_over(workdir) {
let _ = transaction::delete(&git_dir);
git::restore_staged_after_rebase(workdir, &saved_staged);
}
})?;
match outcome {
git::RebaseOutcome::Completed => {
git::restore_staged_after_rebase(workdir, &saved_staged);
transaction::delete(&git_dir)?;
report_reworded(workdir, &ctx);
}
git::RebaseOutcome::Stopped => {
transaction::warn_paused(workdir, "reword");
}
git::RebaseOutcome::Paused => {
transaction::warn_paused_at_edit(Some("reword"));
}
}
Ok(())
}
/// Resume a `reword` after a conflict has been resolved.
pub fn after_continue(
workdir: &Path,
rollback: &Rollback,
context: &serde_json::Value,
) -> Result<()> {
let ctx: RewordContext =
serde_json::from_value(context.clone()).context("Failed to parse reword resume context")?;
git::restore_staged_after_rebase(workdir, &rollback.saved_staged_patch);
report_reworded(workdir, &ctx);
Ok(())
}
fn report_reworded(workdir: &Path, ctx: &RewordContext) {
msg::success(&format!(
"Updated commit message for `{}` (now {})",
ctx.display,
repo::describe_commit(workdir, &ctx.new_hash)
));
}
/// Rename a branch using git branch -m.
pub fn reword_branch(repo: &Repository, old_name: &str, new_name: &str) -> Result<()> {
let workdir = repo::require_workdir(repo, "rename branch")?;
git::branch_rename(workdir, old_name, new_name)?;
branch::warn_if_hidden(repo, new_name);
msg::success(&format!("Renamed branch `{}` to `{}`", old_name, new_name));
Ok(())
}
#[cfg(test)]
#[path = "reword_test.rs"]
mod tests;