use super::*;
fn repo_with_three() -> (tempfile::TempDir, Vec<String>) {
let dir = tempfile::tempdir().unwrap();
let p = dir.path();
git(p, &["init", "-q", "-b", "agent-work"]);
let mk = |name: &str, content: &str, msg: &str| {
std::fs::write(p.join(name), content).unwrap();
git(p, &["add", name]);
git(p, &["commit", "-q", "-m", msg]);
};
mk("a.txt", "v1\n", "c1");
mk("b.txt", "b\n", "c2");
mk("c.txt", "c\n", "c3");
let out = git_cmd(p)
.args(["log", "--format=%H", "--reverse"])
.output()
.unwrap();
let oids = String::from_utf8_lossy(&out.stdout)
.lines()
.map(String::from)
.collect();
(dir, oids)
}
#[test]
fn rebase_rewords_a_middle_commit() {
let (dir, oids) = repo_with_three();
let t = tool(dir.path());
let out = t
.dispatch(
"rebase",
&serde_json::json!({
"onto": oids[0],
"plan": [
{"commit": oids[1], "action": "reword", "message": "b reworded"},
{"commit": oids[2], "action": "pick"},
]
}),
&GitCaveats::top(),
&newt_core::caveats::Caveats::top(),
)
.unwrap();
assert!(out.starts_with("rebased onto"), "got: {out}");
assert_eq!(commit_count(dir.path()), 3, "same number of commits");
let log = git_cmd(dir.path())
.args(["log", "--format=%s", "--reverse"])
.output()
.unwrap();
let subjects = String::from_utf8_lossy(&log.stdout);
assert!(subjects.contains("b reworded"), "got: {subjects}");
assert!(
!subjects.contains("\nc2\n"),
"old c2 subject gone: {subjects}"
);
}
#[test]
fn rebase_pick_commit_receives_canonical_attribution() {
let (dir, oids) = repo_with_three();
let p = dir.path();
let t = tool(p);
t.dispatch(
"rebase",
&serde_json::json!({
"onto": oids[0],
"plan": [
{"commit": oids[1], "action": "pick"},
{"commit": oids[2], "action": "pick"},
]
}),
&GitCaveats::top(),
&newt_core::caveats::Caveats::top(),
)
.unwrap();
let body = head_message(p);
assert!(
body.contains(" | Model: qwen3:30b | "),
"pick commit received the live model provenance: {body}"
);
assert!(
body.contains("Co-authored-by: qwen3:30b (newt-agent v"),
"pick commit received the model Co-authored-by trailer: {body}"
);
let first_line = body.lines().next().unwrap_or("");
assert_eq!(first_line, "c3", "pick preserved the user subject: {body}");
}
#[test]
fn rebase_squashes_two_commits_into_one() {
let (dir, oids) = repo_with_three();
let t = tool(dir.path());
t.dispatch(
"rebase",
&serde_json::json!({
"onto": oids[0],
"plan": [
{"commit": oids[1], "action": "pick"},
{"commit": oids[2], "action": "squash", "message": "folded note"},
]
}),
&GitCaveats::top(),
&newt_core::caveats::Caveats::top(),
)
.unwrap();
assert_eq!(commit_count(dir.path()), 2);
let body = head_message(dir.path());
assert!(
body.contains("c2") && body.contains("folded note"),
"got: {body}"
);
let files = git_cmd(dir.path())
.args(["ls-tree", "--name-only", "-r", "HEAD"])
.output()
.unwrap();
let names = String::from_utf8_lossy(&files.stdout);
assert!(
names.contains("b.txt") && names.contains("c.txt"),
"got: {names}"
);
let status = git_status_porcelain(dir.path());
assert!(status.is_empty(), "worktree must be clean: {status:?}");
let head_tree = String::from_utf8_lossy(
&git_cmd(dir.path())
.args(["rev-parse", "HEAD^{tree}"])
.output()
.unwrap()
.stdout,
)
.trim()
.to_string();
let worktree_tree = String::from_utf8_lossy(
&git_cmd(dir.path())
.args(["write-tree"])
.output()
.unwrap()
.stdout,
)
.trim()
.to_string();
assert_eq!(
head_tree, worktree_tree,
"the checked-out tree must match HEAD's tree"
);
}
#[test]
fn rebase_drops_a_commit() {
let (dir, oids) = repo_with_three();
let t = tool(dir.path());
t.dispatch(
"rebase",
&serde_json::json!({
"onto": oids[0],
"plan": [
{"commit": oids[1], "action": "pick"},
{"commit": oids[2], "action": "drop"},
]
}),
&GitCaveats::top(),
&newt_core::caveats::Caveats::top(),
)
.unwrap();
assert_eq!(commit_count(dir.path()), 2);
let names = git_cmd(dir.path())
.args(["ls-tree", "--name-only", "-r", "HEAD"])
.output()
.unwrap();
let names = String::from_utf8_lossy(&names.stdout);
assert!(
!names.contains("c.txt"),
"dropped commit's file gone: {names}"
);
let status = git_status_porcelain(dir.path());
assert!(status.is_empty(), "worktree must be clean: {status:?}");
assert!(
!dir.path().join("c.txt").exists(),
"c.txt must be gone from the working tree, not just from HEAD's tree"
);
}
#[test]
fn rebase_refuses_on_a_dirty_tree() {
let (dir, oids) = repo_with_three();
let p = dir.path();
std::fs::write(p.join("a.txt"), "dirty\n").unwrap();
let t = tool(p);
let err = t
.dispatch(
"rebase",
&serde_json::json!({
"onto": oids[0],
"plan": [
{"commit": oids[1], "action": "pick"},
{"commit": oids[2], "action": "drop"},
]
}),
&GitCaveats::top(),
&newt_core::caveats::Caveats::top(),
)
.unwrap_err();
assert!(
err.contains("clean working tree"),
"refusal must name the reason: {err}"
);
assert!(
err.contains("0 staged, 1 unstaged, 0 untracked"),
"refusal must name the condition counts: {err}"
);
assert_eq!(commit_count(p), 3, "refusal must not move the ref");
assert_eq!(std::fs::read_to_string(p.join("a.txt")).unwrap(), "dirty\n");
}
#[test]
fn rebase_refuses_when_the_new_tree_would_overwrite_an_ignored_file() {
let dir = tempfile::tempdir().unwrap();
let p = dir.path();
git(p, &["init", "-q", "-b", "agent-work"]);
std::fs::write(p.join(".gitignore"), "x.env\n").unwrap();
std::fs::write(p.join("a.txt"), "v1\n").unwrap();
git(p, &["add", ".gitignore", "a.txt"]);
git(p, &["commit", "-q", "-m", "c1"]);
let onto = String::from_utf8_lossy(
&git_cmd(p)
.args(["rev-parse", "HEAD"])
.output()
.unwrap()
.stdout,
)
.trim()
.to_string();
std::fs::write(p.join("x.env"), "TRACKED\n").unwrap();
git(p, &["add", "-f", "x.env"]);
git(p, &["commit", "-q", "-m", "c2 adds x.env"]);
let c2 = String::from_utf8_lossy(
&git_cmd(p)
.args(["rev-parse", "HEAD"])
.output()
.unwrap()
.stdout,
)
.trim()
.to_string();
git(p, &["rm", "-q", "x.env"]);
git(p, &["commit", "-q", "-m", "c3 removes x.env"]);
std::fs::write(p.join("x.env"), "LOCAL\n").unwrap();
let head_before = String::from_utf8_lossy(
&git_cmd(p)
.args(["rev-parse", "HEAD"])
.output()
.unwrap()
.stdout,
)
.trim()
.to_string();
let t = tool(p);
let err = t
.dispatch(
"rebase",
&serde_json::json!({
"onto": onto,
"plan": [
{"commit": c2, "action": "pick"},
]
}),
&GitCaveats::top(),
&newt_core::caveats::Caveats::top(),
)
.unwrap_err();
assert!(
err.contains("x.env"),
"refusal must name the path it would overwrite: {err}"
);
let head_after = String::from_utf8_lossy(
&git_cmd(p)
.args(["rev-parse", "HEAD"])
.output()
.unwrap()
.stdout,
)
.trim()
.to_string();
assert_eq!(head_before, head_after, "refusal must not move the ref");
assert_eq!(
std::fs::read_to_string(p.join("x.env")).unwrap(),
"LOCAL\n",
"the local ignored file must be untouched"
);
}
#[test]
fn rebase_all_drop_preserves_pending_contributors() {
let (dir, oids) = repo_with_three();
let mut t = tool(dir.path());
if let Some(a) = t.attribution.as_mut() {
a.contributors.push(
newt_core::attribution::Attribution::new(
"model-a",
"newt-agent",
newt_core::build_info::PACKAGE_VERSION,
"noreply@newt-agent.com",
)
.with_build(newt_core::build_info::SOURCE_ID),
);
}
let out = t
.dispatch(
"rebase",
&serde_json::json!({
"onto": oids[0],
"plan": [
{"commit": oids[1], "action": "drop"},
{"commit": oids[2], "action": "drop"},
]
}),
&GitCaveats::top(),
&newt_core::caveats::Caveats::top(),
)
.unwrap();
assert!(
out.contains("0 commit(s), 2 dropped"),
"all-drop rebase produced 0 commits: {out}"
);
assert_eq!(
t.drain_commit_success(),
0,
"a 0-produced rebase must NOT report commit_succeeded"
);
std::fs::write(dir.path().join("d.txt"), "z\n").unwrap();
t.dispatch(
"add",
&serde_json::json!({"paths": ["d.txt"]}),
&GitCaveats::top(),
&newt_core::caveats::Caveats::top(),
)
.unwrap();
t.dispatch(
"commit",
&serde_json::json!({"message": "after all-drop rebase"}),
&GitCaveats::top(),
&newt_core::caveats::Caveats::top(),
)
.unwrap();
let msg = head_message(dir.path());
let version = newt_core::build_info::PACKAGE_VERSION;
let build = newt_core::build_info::SOURCE_ID;
assert!(
msg.contains(&format!(
"Co-authored-by: model-a (newt-agent v{version} {build}) <noreply@newt-agent.com>"
)),
"the pending contributor survived the 0-produced rebase and is credited on the next commit: {msg}"
);
assert_eq!(
t.drain_commit_success(),
1,
"the commit after the all-drop rebase signals normally"
);
}
#[test]
fn rebase_aborts_on_conflict_leaving_the_branch_unchanged() {
let dir = tempfile::tempdir().unwrap();
let p = dir.path();
git(p, &["init", "-q", "-b", "agent-work"]);
let mk = |content: &str, msg: &str| {
std::fs::write(p.join("a.txt"), content).unwrap();
git(p, &["add", "a.txt"]);
git(p, &["commit", "-q", "-m", msg]);
};
mk("v1\n", "c1");
mk("v2\n", "c2");
mk("v3\n", "c3");
let head_before = git_cmd(p).args(["rev-parse", "HEAD"]).output().unwrap();
let oids: Vec<String> = String::from_utf8_lossy(
&git_cmd(p)
.args(["log", "--format=%H", "--reverse"])
.output()
.unwrap()
.stdout,
)
.lines()
.map(String::from)
.collect();
let t = tool(p);
let err = t
.dispatch(
"rebase",
&serde_json::json!({
"onto": oids[0],
"plan": [{"commit": oids[2], "action": "pick"}]
}),
&GitCaveats::top(),
&newt_core::caveats::Caveats::top(),
)
.unwrap_err();
assert!(
err.contains("conflict") && err.contains("aborted"),
"got: {err}"
);
let head_after = git_cmd(p).args(["rev-parse", "HEAD"]).output().unwrap();
assert_eq!(
head_before.stdout, head_after.stdout,
"branch must be unchanged"
);
}
#[test]
fn rebase_denied_on_read_only() {
let (dir, oids) = repo_with_three();
let t = tool(dir.path());
let err = t
.dispatch(
"rebase",
&serde_json::json!({"onto": oids[0], "plan": [{"commit": oids[1], "action": "pick"}]}),
&GitCaveats::read_only(),
&newt_core::caveats::Caveats::top(),
)
.unwrap_err();
assert!(
err.contains("denied") && err.contains("commit"),
"got: {err}"
);
}
#[test]
fn local_git_tool_unknown_op_and_missing_args_error() {
let dir = repo_with_commit();
let t = tool(dir.path());
let err = t
.dispatch(
"frobnicate",
&serde_json::json!({}),
&GitCaveats::top(),
&newt_core::caveats::Caveats::top(),
)
.unwrap_err();
assert!(err.contains("unknown git op"), "got: {err}");
let err = t
.dispatch(
"commit",
&serde_json::json!({}),
&GitCaveats::top(),
&newt_core::caveats::Caveats::top(),
)
.unwrap_err();
assert!(err.contains("message"), "got: {err}");
}