/* git/rebase/tests.rs
 *
 * Copyright 2026 Christian Kruse
 *
 * This program is free software: you can redistribute it and/or modify
 * it under the terms of the GNU Affero General Public License as published by
 * the Free Software Foundation, either version 3 of the License, or
 * (at your option) any later version.
 *
 * This program is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 * GNU Affero General Public License for more details.
 *
 * You should have received a copy of the GNU Affero General Public License
 * along with this program.  If not, see <https://www.gnu.org/licenses/>.
 *
 * SPDX-License-Identifier: AGPL-3.0-or-later
 */

use std::path::Path;

use super::super::test_helpers::*;
use super::*;

#[test]
fn squash_three_contiguous_at_head_with_new_message() {
    let r = TestRepo::new();
    let _c0 = r.commit("base", "base.txt", "0");
    let c1 = r.commit("first", "a.txt", "A1");
    let c2 = r.commit("second", "b.txt", "B1");
    let c3 = r.commit("third", "c.txt", "C1");

    let original_tree = r.head_tree_oid();

    let outcome =
        squash_commits(&r.path, &gc(), &[s(c1), s(c2), s(c3)], SquashMode::Squash, Some("squashed".to_string()))
            .unwrap();

    assert!(matches!(outcome, RebaseOutcome::Completed { .. }));
    assert_eq!(r.head_tree_oid(), original_tree);
    assert_eq!(r.first_parent_chain_len(), 2);
    assert_eq!(r.head_message().trim(), "squashed");
}

#[test]
fn squash_three_with_combined_message() {
    let r = TestRepo::new();
    let _c0 = r.commit("base", "base.txt", "0");
    let c1 = r.commit("first", "a.txt", "A1");
    let c2 = r.commit("second", "b.txt", "B1");
    let c3 = r.commit("third", "c.txt", "C1");

    squash_commits(&r.path, &gc(), &[s(c1), s(c2), s(c3)], SquashMode::Squash, None).unwrap();

    let msg = r.head_message();
    assert!(msg.contains("first"), "{msg}");
    assert!(msg.contains("second"), "{msg}");
    assert!(msg.contains("third"), "{msg}");
}

#[test]
fn fixup_keeps_only_oldest_message() {
    let r = TestRepo::new();
    let _c0 = r.commit("base", "base.txt", "0");
    let c1 = r.commit("first", "a.txt", "A1");
    let c2 = r.commit("second", "b.txt", "B1");
    let c3 = r.commit("third", "c.txt", "C1");

    squash_commits(&r.path, &gc(), &[s(c1), s(c2), s(c3)], SquashMode::Fixup, None).unwrap();

    let msg = r.head_message();
    assert!(msg.contains("first"), "{msg}");
    assert!(!msg.contains("second"), "{msg}");
    assert!(!msg.contains("third"), "{msg}");
}

#[test]
fn squash_non_contiguous_keeps_intermediate_commits() {
    let r = TestRepo::new();
    let _c0 = r.commit("base", "base.txt", "0");
    let c1 = r.commit("c1", "a.txt", "A1");
    let _c2 = r.commit("c2", "b.txt", "B1");
    let c3 = r.commit("c3", "c.txt", "C1");
    let _c4 = r.commit("c4", "d.txt", "D1");

    let original_tree = r.head_tree_oid();

    squash_commits(&r.path, &gc(), &[s(c1), s(c3)], SquashMode::Squash, Some("merged".to_string())).unwrap();

    assert_eq!(r.first_parent_chain_len(), 4);
    assert_eq!(r.head_tree_oid(), original_tree);
}

#[test]
fn squash_selection_below_head_replays_newer_commits() {
    let r = TestRepo::new();
    let _c0 = r.commit("base", "base.txt", "0");
    let c1 = r.commit("c1", "a.txt", "A1");
    let c2 = r.commit("c2", "b.txt", "B1");
    let _c3 = r.commit("c3", "c.txt", "C1");
    let _c4 = r.commit("c4", "d.txt", "D1");

    let original_tree = r.head_tree_oid();

    squash_commits(&r.path, &gc(), &[s(c1), s(c2)], SquashMode::Squash, Some("merged".to_string())).unwrap();

    assert_eq!(r.first_parent_chain_len(), 4);
    assert_eq!(r.head_tree_oid(), original_tree);
    assert!(r.read_head_blob("d.txt") == "D1");
}

#[test]
fn squash_works_on_detached_head() {
    let r = TestRepo::new();
    let _c0 = r.commit("base", "base.txt", "0");
    let c1 = r.commit("c1", "a.txt", "A1");
    let c2 = r.commit("c2", "b.txt", "B1");
    let c3 = r.commit("c3", "c.txt", "C1");

    r.repo.set_head_detached(c3).unwrap();

    squash_commits(&r.path, &gc(), &[s(c1), s(c2), s(c3)], SquashMode::Squash, Some("squashed".to_string())).unwrap();

    assert!(r.repo.head_detached().unwrap());
    assert_eq!(r.first_parent_chain_len(), 2);
}

#[test]
fn squash_rejects_fewer_than_two_commits() {
    let r = TestRepo::new();
    let c1 = r.commit("base", "a.txt", "A");

    let err = squash_commits(&r.path, &gc(), &[s(c1)], SquashMode::Squash, None).unwrap_err();
    assert!(err.contains("at least two"), "{err}");
}

#[test]
fn squash_rejects_invalid_oid() {
    let r = TestRepo::new();
    let c1 = r.commit("base", "a.txt", "A");
    let _c2 = r.commit("c2", "b.txt", "B");

    let err =
        squash_commits(&r.path, &gc(), &[s(c1), "not-a-valid-oid".to_string()], SquashMode::Squash, None).unwrap_err();
    assert!(err.contains("Invalid OID"), "{err}");
}

#[test]
fn squash_rejects_unreachable_oid() {
    let r = TestRepo::new();
    let c1 = r.commit("base", "a.txt", "A");

    r.create_branch_at("side", c1);
    r.set_head_to_branch("side");
    let s1 = r.commit("side commit", "s.txt", "S");
    let main = r.main_branch.clone();

    r.set_head_to_branch(&main);
    let c2 = r.commit("c2", "b.txt", "B");
    let _c3 = r.commit("c3", "c.txt", "C");

    let err = squash_commits(&r.path, &gc(), &[s(c2), s(s1)], SquashMode::Squash, None).unwrap_err();
    assert!(err.contains("not all reachable"), "{err}");
}

#[test]
fn squash_rejects_selected_merge_commit() {
    let r = TestRepo::new();
    let c1 = r.commit("base", "a.txt", "A");

    r.create_branch_at("side", c1);
    r.set_head_to_branch("side");
    let s1 = r.commit("side", "s.txt", "S");
    let main = r.main_branch.clone();
    r.set_head_to_branch(&main);

    let c2 = r.commit("c2", "b.txt", "B");
    let m = r.merge_commit("merge", &[c2, s1]);

    let err = squash_commits(&r.path, &gc(), &[s(m), s(c2)], SquashMode::Squash, None).unwrap_err();
    assert!(err.contains("merge"), "{err}");
}

#[test]
fn squash_rejects_merge_commit_in_rewrite_range() {
    let r = TestRepo::new();
    let c1 = r.commit("base", "a.txt", "A");

    r.create_branch_at("side", c1);
    r.set_head_to_branch("side");
    let s1 = r.commit("side", "s.txt", "S");
    let main = r.main_branch.clone();
    r.set_head_to_branch(&main);

    let c2 = r.commit("c2", "b.txt", "B");
    let _m = r.merge_commit("merge", &[c2, s1]);
    let c3 = r.commit("c3", "c.txt", "C");

    let err = squash_commits(&r.path, &gc(), &[s(c2), s(c3)], SquashMode::Squash, None).unwrap_err();
    assert!(err.contains("merge"), "{err}");
}

#[test]
fn squash_rejects_root_commit() {
    let r = TestRepo::new();
    let root = r.commit("root", "a.txt", "A");
    let c2 = r.commit("c2", "b.txt", "B");

    let err = squash_commits(&r.path, &gc(), &[s(root), s(c2)], SquashMode::Squash, None).unwrap_err();
    assert!(err.contains("root commit"), "{err}");
}

#[test]
fn squash_rejects_dirty_working_copy() {
    let r = TestRepo::new();
    let _c0 = r.commit("base", "a.txt", "A");
    let c1 = r.commit("c1", "b.txt", "B");
    let c2 = r.commit("c2", "c.txt", "C");

    r.write_file("a.txt", "MODIFIED");

    let err = squash_commits(&r.path, &gc(), &[s(c1), s(c2)], SquashMode::Squash, None).unwrap_err();
    assert!(err.contains("commit or stash"), "{err}");
}

#[test]
fn squash_allows_untracked_only_dirty() {
    let r = TestRepo::new();
    let _c0 = r.commit("base", "a.txt", "A");
    let c1 = r.commit("c1", "b.txt", "B");
    let c2 = r.commit("c2", "c.txt", "C");

    r.write_file("untracked.txt", "hello");

    squash_commits(&r.path, &gc(), &[s(c1), s(c2)], SquashMode::Squash, Some("merged".to_string())).unwrap();

    assert_eq!(r.first_parent_chain_len(), 2);
}

#[test]
fn squash_pauses_rebase_on_conflict() {
    let r = TestRepo::new();
    let _base = r.commit("base", "f.txt", "X\n");
    let c1 = r.commit("c1", "f.txt", "A\n");
    let _c2 = r.commit("c2", "f.txt", "B\n");
    let c3 = r.commit("c3", "f.txt", "C\n");

    let outcome = squash_commits(&r.path, &gc(), &[s(c1), s(c3)], SquashMode::Squash, Some("x".to_string())).unwrap();

    assert_eq!(outcome, RebaseOutcome::Paused { has_conflicts: true });
    assert!(matches!(
        r.repo.state(),
        git2::RepositoryState::Rebase | git2::RepositoryState::RebaseInteractive | git2::RepositoryState::RebaseMerge
    ));
    assert!(r.fresh_index().has_conflicts(), "index must reflect the conflict");
}

#[test]
fn load_squash_messages_returns_oldest_first_regardless_of_input_order() {
    let r = TestRepo::new();
    let _root = r.commit("root", "f.txt", "0");
    let c1 = r.commit("first", "a.txt", "A");
    let c2 = r.commit("second", "b.txt", "B");
    let c3 = r.commit("third", "c.txt", "C");

    let result = load_squash_messages(&r.path, &[s(c3), s(c1), s(c2)]).unwrap();

    let subjects: Vec<&str> = result.iter().map(|m| m.subject.as_str()).collect();
    assert_eq!(subjects, vec!["first", "second", "third"]);
}

#[test]
fn load_squash_messages_splits_subject_and_body() {
    let r = TestRepo::new();
    let _root = r.commit("root", "f.txt", "0");
    let c1 = r.commit("subject line\n\nbody line one\nbody line two", "a.txt", "A");
    let _c2 = r.commit("c2", "b.txt", "B");

    let result = load_squash_messages(&r.path, &[s(c1)]).unwrap();

    assert_eq!(result.len(), 1);
    assert_eq!(result[0].subject, "subject line");
    assert!(result[0].body.contains("body line one"));
    assert!(result[0].body.contains("body line two"));
}

#[test]
fn load_squash_messages_rejects_unreachable_oid() {
    let r = TestRepo::new();
    let c1 = r.commit("base", "a.txt", "A");

    r.create_branch_at("side", c1);
    r.set_head_to_branch("side");
    let s1 = r.commit("side", "s.txt", "S");
    let main = r.main_branch.clone();
    r.set_head_to_branch(&main);

    let _c2 = r.commit("c2", "b.txt", "B");

    let err = load_squash_messages(&r.path, &[s(s1)]).unwrap_err();
    assert!(err.contains("not reachable"), "{err}");
}

#[test]
fn reorder_swaps_two_adjacent_commits() {
    let r = TestRepo::new();
    let _base = r.commit("base", "base.txt", "0");
    let c1 = r.commit("first", "a.txt", "A1");
    let c2 = r.commit("second", "b.txt", "B1");

    let original_tree = r.head_tree_oid();

    reorder_commits(&r.path, &gc(), &[s(c2), s(c1)]).unwrap();

    assert_eq!(r.first_parent_chain_len(), 3);
    assert_eq!(r.head_tree_oid(), original_tree);
    assert!(r.head_message().contains("first"));
    let parent = r.repo.head().unwrap().peel_to_commit().unwrap().parent(0).unwrap();
    assert!(parent.message().unwrap().contains("second"));
}

#[test]
fn reorder_three_commits_full_reverse() {
    let r = TestRepo::new();
    let _base = r.commit("base", "base.txt", "0");
    let c1 = r.commit("first", "a.txt", "A");
    let c2 = r.commit("second", "b.txt", "B");
    let c3 = r.commit("third", "c.txt", "C");

    let original_tree = r.head_tree_oid();

    reorder_commits(&r.path, &gc(), &[s(c3), s(c2), s(c1)]).unwrap();

    assert_eq!(r.first_parent_chain_len(), 4);
    assert_eq!(r.head_tree_oid(), original_tree);
    assert!(r.head_message().contains("first"));
}

#[test]
fn reorder_pauses_rebase_on_conflict() {
    let r = TestRepo::new();
    let _base = r.commit("base", "f.txt", "X\n");
    let c1 = r.commit("c1", "f.txt", "A\n");
    let c2 = r.commit("c2", "f.txt", "B\n");

    let outcome = reorder_commits(&r.path, &gc(), &[s(c2), s(c1)]).unwrap();

    assert_eq!(outcome, RebaseOutcome::Paused { has_conflicts: true });
    assert!(matches!(
        r.repo.state(),
        git2::RepositoryState::Rebase | git2::RepositoryState::RebaseInteractive | git2::RepositoryState::RebaseMerge
    ));
    assert!(r.fresh_index().has_conflicts());
}

#[test]
fn reorder_rejects_merge_commit_in_range() {
    let r = TestRepo::new();
    let c1 = r.commit("base", "a.txt", "A");

    r.create_branch_at("side", c1);
    r.set_head_to_branch("side");
    let s1 = r.commit("side", "s.txt", "S");
    let main = r.main_branch.clone();
    r.set_head_to_branch(&main);

    let c2 = r.commit("c2", "b.txt", "B");
    let m = r.merge_commit("merge", &[c2, s1]);
    let c3 = r.commit("c3", "c.txt", "C");

    let err = reorder_commits(&r.path, &gc(), &[s(c2), s(m), s(c3)]).unwrap_err();
    assert!(err.contains("merge") || err.contains("contiguous"), "{err}");
}

#[test]
fn reorder_rejects_dirty_working_copy() {
    let r = TestRepo::new();
    let _base = r.commit("base", "a.txt", "A");
    let c1 = r.commit("c1", "b.txt", "B");
    let c2 = r.commit("c2", "c.txt", "C");

    r.write_file("a.txt", "MODIFIED");

    let err = reorder_commits(&r.path, &gc(), &[s(c2), s(c1)]).unwrap_err();
    assert!(err.contains("commit or stash"), "{err}");
}

#[test]
fn reorder_rejects_non_contiguous_range() {
    let r = TestRepo::new();
    let _base = r.commit("base", "base.txt", "0");
    let c1 = r.commit("c1", "a.txt", "A");
    let _c2 = r.commit("c2", "b.txt", "B");
    let c3 = r.commit("c3", "c.txt", "C");

    let err = reorder_commits(&r.path, &gc(), &[s(c1), s(c3)]).unwrap_err();
    assert!(err.contains("contiguous"), "{err}");
}

#[test]
fn reorder_rejects_fewer_than_two_commits() {
    let r = TestRepo::new();
    let c1 = r.commit("base", "a.txt", "A");

    let err = reorder_commits(&r.path, &gc(), &[s(c1)]).unwrap_err();
    assert!(err.contains("at least two"), "{err}");
}

#[test]
fn reorder_rejects_unreachable_oid() {
    let r = TestRepo::new();
    let c1 = r.commit("base", "a.txt", "A");

    r.create_branch_at("side", c1);
    r.set_head_to_branch("side");
    let s1 = r.commit("side", "s.txt", "S");
    let main = r.main_branch.clone();
    r.set_head_to_branch(&main);

    let c2 = r.commit("c2", "b.txt", "B");

    let err = reorder_commits(&r.path, &gc(), &[s(s1), s(c2)]).unwrap_err();
    assert!(err.contains("contiguous") || err.contains("not all reachable"), "{err}");
}

#[test]
fn reword_changes_message_and_replays_descendants() {
    let r = TestRepo::new();
    let _base = r.commit("base", "base.txt", "0");
    let c1 = r.commit("first", "a.txt", "A");
    let c2 = r.commit("second", "b.txt", "B");
    let c3 = r.commit("third", "c.txt", "C");

    let original_tree = r.head_tree_oid();

    reword_commit(&r.path, &gc(), &s(c1), "first reworded").unwrap();

    assert_eq!(r.first_parent_chain_len(), 4);
    assert_eq!(r.head_tree_oid(), original_tree);
    assert!(r.head_message().contains("third"));

    let mut current = r.repo.head().unwrap().peel_to_commit().unwrap();
    while current.message().unwrap_or("").trim() != "first reworded" {
        current = current.parent(0).unwrap();
    }
    assert_ne!(current.id(), c1);
    assert_ne!(r.head_oid(), c3);
    let _ = c2;
}

#[test]
fn reword_at_head_just_replaces_message() {
    let r = TestRepo::new();
    let _base = r.commit("base", "base.txt", "0");
    let c1 = r.commit("only", "a.txt", "A");

    let original_tree = r.head_tree_oid();

    reword_commit(&r.path, &gc(), &s(c1), "only reworded").unwrap();

    assert_eq!(r.first_parent_chain_len(), 2);
    assert_eq!(r.head_tree_oid(), original_tree);
    assert_eq!(r.head_message().trim(), "only reworded");
    assert_ne!(r.head_oid(), c1);
}

#[test]
fn reword_preserves_author_and_tree_of_target() {
    let r = TestRepo::new();
    let _base = r.commit("base", "base.txt", "0");
    let c1 = r.commit("first", "a.txt", "A");
    let _c2 = r.commit("second", "b.txt", "B");

    let original_author_email = r
        .repo
        .find_commit(c1)
        .unwrap()
        .author()
        .email()
        .unwrap_or("")
        .to_string();
    let original_target_tree = r.repo.find_commit(c1).unwrap().tree().unwrap().id();

    reword_commit(&r.path, &gc(), &s(c1), "first reworded").unwrap();

    let mut current = r.repo.head().unwrap().peel_to_commit().unwrap();
    while current.message().unwrap_or("").trim() != "first reworded" {
        current = current.parent(0).unwrap();
    }
    assert_eq!(current.author().email().unwrap_or(""), original_author_email);
    assert_eq!(current.tree().unwrap().id(), original_target_tree);
}

#[test]
fn reword_rejects_root_commit() {
    let r = TestRepo::new();
    let root = r.commit("root", "a.txt", "A");
    let _c2 = r.commit("c2", "b.txt", "B");

    let err = reword_commit(&r.path, &gc(), &s(root), "new").unwrap_err();
    assert!(err.contains("root commit"), "{err}");
}

#[test]
fn reword_rejects_merge_in_path() {
    let r = TestRepo::new();
    let c1 = r.commit("base", "a.txt", "A");

    r.create_branch_at("side", c1);
    r.set_head_to_branch("side");
    let s1 = r.commit("side", "s.txt", "S");
    let main = r.main_branch.clone();
    r.set_head_to_branch(&main);

    let c2 = r.commit("c2", "b.txt", "B");
    let _m = r.merge_commit("merge", &[c2, s1]);
    let _c3 = r.commit("c3", "c.txt", "C");

    let err = reword_commit(&r.path, &gc(), &s(c2), "new").unwrap_err();
    assert!(err.contains("merge"), "{err}");
}

#[test]
fn reword_rejects_unreachable_commit() {
    let r = TestRepo::new();
    let c1 = r.commit("base", "a.txt", "A");

    r.create_branch_at("side", c1);
    r.set_head_to_branch("side");
    let s1 = r.commit("side", "s.txt", "S");
    let main = r.main_branch.clone();
    r.set_head_to_branch(&main);

    let _c2 = r.commit("c2", "b.txt", "B");

    let err = reword_commit(&r.path, &gc(), &s(s1), "new").unwrap_err();
    assert!(err.contains("not reachable"), "{err}");
}

#[test]
fn reword_rejects_dirty_working_copy() {
    let r = TestRepo::new();
    let _base = r.commit("base", "a.txt", "A");
    let c1 = r.commit("c1", "b.txt", "B");

    r.write_file("a.txt", "MODIFIED");

    let err = reword_commit(&r.path, &gc(), &s(c1), "new").unwrap_err();
    assert!(err.contains("commit or stash"), "{err}");
}

#[test]
fn drop_commit_in_middle_replays_descendants() {
    let r = TestRepo::new();
    let _base = r.commit("base", "base.txt", "0");
    let _c1 = r.commit("first", "a.txt", "A");
    let c2 = r.commit("second", "b.txt", "B");
    let c3 = r.commit("third", "c.txt", "C");

    drop_commit(&r.path, &gc(), &s(c2)).unwrap();

    assert_eq!(r.first_parent_chain_len(), 3);
    assert!(r.head_message().contains("third"));
    assert_ne!(r.head_oid(), c3);

    let head_tree = r.repo.head().unwrap().peel_to_commit().unwrap().tree().unwrap();
    assert!(head_tree.get_path(Path::new("a.txt")).is_ok());
    assert!(head_tree.get_path(Path::new("c.txt")).is_ok());
    assert!(head_tree.get_path(Path::new("b.txt")).is_err());
}

#[test]
fn drop_at_head_moves_head_to_parent() {
    let r = TestRepo::new();
    let _base = r.commit("base", "base.txt", "0");
    let c1 = r.commit("c1", "a.txt", "A");
    let c2 = r.commit("c2", "b.txt", "B");

    drop_commit(&r.path, &gc(), &s(c2)).unwrap();

    assert_eq!(r.first_parent_chain_len(), 2);
    assert_eq!(r.head_oid(), c1);
    assert!(r.head_message().contains("c1"));
}

#[test]
fn drop_works_on_detached_head() {
    let r = TestRepo::new();
    let _base = r.commit("base", "base.txt", "0");
    let _c1 = r.commit("c1", "a.txt", "A");
    let c2 = r.commit("c2", "b.txt", "B");
    let c3 = r.commit("c3", "c.txt", "C");

    r.repo.set_head_detached(c3).unwrap();

    drop_commit(&r.path, &gc(), &s(c2)).unwrap();

    assert!(r.repo.head_detached().unwrap());
    assert_eq!(r.first_parent_chain_len(), 3);
}

#[test]
fn drop_rejects_root_commit() {
    let r = TestRepo::new();
    let root = r.commit("root", "a.txt", "A");
    let _c2 = r.commit("c2", "b.txt", "B");

    let err = drop_commit(&r.path, &gc(), &s(root)).unwrap_err();
    assert!(err.contains("root commit"), "{err}");
}

#[test]
fn drop_rejects_target_merge_commit() {
    let r = TestRepo::new();
    let c1 = r.commit("base", "a.txt", "A");

    r.create_branch_at("side", c1);
    r.set_head_to_branch("side");
    let s1 = r.commit("side", "s.txt", "S");
    let main = r.main_branch.clone();
    r.set_head_to_branch(&main);
    let c2 = r.commit("c2", "b.txt", "B");
    let m = r.merge_commit("merge", &[c2, s1]);

    let err = drop_commit(&r.path, &gc(), &s(m)).unwrap_err();
    assert!(err.contains("merge"), "{err}");
}

#[test]
fn drop_rejects_dirty_working_copy() {
    let r = TestRepo::new();
    let _base = r.commit("base", "a.txt", "A");
    let _c1 = r.commit("c1", "b.txt", "B");
    let c2 = r.commit("c2", "c.txt", "C");

    r.write_file("a.txt", "MODIFIED");

    let err = drop_commit(&r.path, &gc(), &s(c2)).unwrap_err();
    assert!(err.contains("commit or stash"), "{err}");
}

#[test]
fn drop_rejects_invalid_oid() {
    let r = TestRepo::new();
    let _c1 = r.commit("base", "a.txt", "A");
    let _c2 = r.commit("c2", "b.txt", "B");

    let err = drop_commit(&r.path, &gc(), "not-a-valid-oid").unwrap_err();
    assert!(err.contains("Invalid OID"), "{err}");
}

#[test]
fn drop_pauses_rebase_on_conflict() {
    let r = TestRepo::new();
    let _base = r.commit("base", "f.txt", "X\n");
    let _c1 = r.commit("c1", "f.txt", "A\n");
    let c2 = r.commit("c2", "f.txt", "B\n");
    let _c3 = r.commit("c3", "f.txt", "C\n");

    let outcome = drop_commit(&r.path, &gc(), &s(c2)).unwrap();

    assert_eq!(outcome, RebaseOutcome::Paused { has_conflicts: true });
    assert!(matches!(
        r.repo.state(),
        git2::RepositoryState::Rebase | git2::RepositoryState::RebaseInteractive | git2::RepositoryState::RebaseMerge
    ));
    assert!(r.fresh_index().has_conflicts());
}

#[test]
fn continue_after_resolving_conflict_completes_drop() {
    let r = TestRepo::new();
    let _base = r.commit("base", "f.txt", "X\n");
    let _c1 = r.commit("c1", "f.txt", "A\n");
    let c2 = r.commit("c2", "f.txt", "B\n");
    let _c3 = r.commit("c3", "f.txt", "C\n");

    let outcome = drop_commit(&r.path, &gc(), &s(c2)).unwrap();
    assert_eq!(outcome, RebaseOutcome::Paused { has_conflicts: true });

    // Resolve the conflict by writing a clean version and staging it
    r.write_file("f.txt", "C\n");
    let mut idx = r.fresh_index();
    idx.add_path(Path::new("f.txt")).unwrap();
    idx.write().unwrap();

    let outcome = rebase_continue(&r.path, &gc()).unwrap();
    assert!(matches!(outcome, RebaseOutcome::Completed { .. }));
    assert_eq!(r.repo.state(), git2::RepositoryState::Clean);
    assert!(r.head_message().contains("c3"));
}

#[test]
fn abort_restores_original_head() {
    let r = TestRepo::new();
    let _base = r.commit("base", "f.txt", "X\n");
    let _c1 = r.commit("c1", "f.txt", "A\n");
    let c2 = r.commit("c2", "f.txt", "B\n");
    let c3 = r.commit("c3", "f.txt", "C\n");

    let head_before = r.head_oid();

    let outcome = drop_commit(&r.path, &gc(), &s(c2)).unwrap();
    assert_eq!(outcome, RebaseOutcome::Paused { has_conflicts: true });

    rebase_abort(&r.path, &gc()).unwrap();

    assert_eq!(r.repo.state(), git2::RepositoryState::Clean);
    assert_eq!(r.head_oid(), head_before);
    assert_eq!(r.head_oid(), c3);
}

#[test]
fn reorder_reports_applied_commit_count() {
    let r = TestRepo::new();
    let _base = r.commit("base", "base.txt", "0");
    let c1 = r.commit("c1", "a.txt", "A");
    let c2 = r.commit("c2", "b.txt", "B");
    let c3 = r.commit("c3", "c.txt", "C");

    let outcome = reorder_commits(&r.path, &gc(), &[s(c3), s(c2), s(c1)]).unwrap();
    assert_eq!(outcome, RebaseOutcome::Completed { commits: Some(3) });
}

#[test]
fn drop_reports_applied_commit_count() {
    let r = TestRepo::new();
    let _base = r.commit("base", "base.txt", "0");
    let _c1 = r.commit("c1", "a.txt", "A");
    let c2 = r.commit("c2", "b.txt", "B");
    let _c3 = r.commit("c3", "c.txt", "C");

    let outcome = drop_commit(&r.path, &gc(), &s(c2)).unwrap();
    assert_eq!(outcome, RebaseOutcome::Completed { commits: Some(1) });
}

#[test]
fn drop_only_commit_reports_no_count() {
    let r = TestRepo::new();
    let _base = r.commit("base", "base.txt", "0");
    let c1 = r.commit("c1", "a.txt", "A");

    let outcome = drop_commit(&r.path, &gc(), &s(c1)).unwrap();
    assert_eq!(outcome, RebaseOutcome::Completed { commits: None });
}

#[test]
fn squash_reports_applied_commit_count() {
    let r = TestRepo::new();
    let _base = r.commit("base", "base.txt", "0");
    let c1 = r.commit("c1", "a.txt", "A");
    let c2 = r.commit("c2", "b.txt", "B");
    let c3 = r.commit("c3", "c.txt", "C");

    let outcome =
        squash_commits(&r.path, &gc(), &[s(c1), s(c2), s(c3)], SquashMode::Squash, Some("x".to_string())).unwrap();
    assert_eq!(outcome, RebaseOutcome::Completed { commits: Some(1) });
}

#[test]
fn squash_partial_reports_applied_commit_count() {
    let r = TestRepo::new();
    let _base = r.commit("base", "base.txt", "0");
    let _c1 = r.commit("c1", "a.txt", "A");
    let c2 = r.commit("c2", "b.txt", "B");
    let c3 = r.commit("c3", "c.txt", "C");
    let _c4 = r.commit("c4", "d.txt", "D");

    let outcome = squash_commits(&r.path, &gc(), &[s(c2), s(c3)], SquashMode::Squash, Some("x".to_string())).unwrap();

    assert_eq!(outcome, RebaseOutcome::Completed { commits: Some(2) });
}

#[test]
fn reword_reports_applied_commit_count() {
    let r = TestRepo::new();
    let _base = r.commit("base", "base.txt", "0");
    let c1 = r.commit("c1", "a.txt", "A");
    let _c2 = r.commit("c2", "b.txt", "B");

    let outcome = reword_commit(&r.path, &gc(), &s(c1), "REWORDED").unwrap();
    assert_eq!(outcome, RebaseOutcome::Completed { commits: Some(2) });
}

#[test]
fn continue_reports_applied_commit_count() {
    let r = TestRepo::new();
    let _base = r.commit("base", "f.txt", "X\n");
    let _c1 = r.commit("c1", "f.txt", "A\n");
    let c2 = r.commit("c2", "f.txt", "B\n");
    let _c3 = r.commit("c3", "f.txt", "C\n");

    drop_commit(&r.path, &gc(), &s(c2)).unwrap();

    r.write_file("f.txt", "C\n");
    let mut idx = r.fresh_index();
    idx.add_path(Path::new("f.txt")).unwrap();
    idx.write().unwrap();

    let outcome = rebase_continue(&r.path, &gc()).unwrap();
    assert_eq!(outcome, RebaseOutcome::Completed { commits: Some(1) });
}

#[test]
fn head_rewriteable_oids_linear_history_excludes_root() {
    let r = TestRepo::new();
    let _root = r.commit("root", "base.txt", "0");
    let c1 = r.commit("c1", "a.txt", "A");
    let c2 = r.commit("c2", "b.txt", "B");
    let c3 = r.commit("c3", "c.txt", "C");

    let set = head_rewriteable_oids(&r.path).unwrap();
    assert_eq!(set.len(), 3);
    assert!(set.contains(&s(c1)));
    assert!(set.contains(&s(c2)));
    assert!(set.contains(&s(c3)));
}

#[test]
fn head_rewriteable_oids_only_root_returns_empty() {
    let r = TestRepo::new();
    let _root = r.commit("root", "a.txt", "A");

    let set = head_rewriteable_oids(&r.path).unwrap();
    assert!(set.is_empty());
}

#[test]
fn head_rewriteable_oids_stops_at_first_merge() {
    let r = TestRepo::new();
    let base = r.commit("base", "a.txt", "A");

    r.create_branch_at("side", base);
    r.set_head_to_branch("side");
    let s1 = r.commit("side", "s.txt", "S");
    let main = r.main_branch.clone();
    r.set_head_to_branch(&main);

    let c2 = r.commit("c2", "b.txt", "B");
    let m = r.merge_commit("merge", &[c2, s1]);
    let c3 = r.commit("c3", "c.txt", "C");

    let set = head_rewriteable_oids(&r.path).unwrap();
    assert_eq!(set.len(), 1);
    assert!(set.contains(&s(c3)));
    assert!(!set.contains(&s(m)));
    assert!(!set.contains(&s(c2)));
}

#[test]
fn head_rewriteable_oids_works_on_detached_head() {
    let r = TestRepo::new();
    let _root = r.commit("root", "base.txt", "0");
    let c1 = r.commit("c1", "a.txt", "A");
    let c2 = r.commit("c2", "b.txt", "B");
    let _c3 = r.commit("c3", "c.txt", "C");

    r.repo.set_head_detached(c2).unwrap();

    let set = head_rewriteable_oids(&r.path).unwrap();
    assert_eq!(set.len(), 2);
    assert!(set.contains(&s(c1)));
    assert!(set.contains(&s(c2)));
}

#[test]
fn head_rewriteable_oids_empty_repo_returns_empty() {
    let r = TestRepo::new();
    let set = head_rewriteable_oids(&r.path).unwrap();
    assert!(set.is_empty());
}

#[test]
fn edit_commit_pauses_with_clean_index() {
    let r = TestRepo::new();
    let _root = r.commit("root", "f.txt", "0");
    let c1 = r.commit("c1", "a.txt", "A");
    let _c2 = r.commit("c2", "b.txt", "B");

    let outcome = edit_commit(&r.path, &gc(), &s(c1)).unwrap();

    assert_eq!(outcome, RebaseOutcome::Paused { has_conflicts: false });
    assert!(matches!(
        r.repo.state(),
        git2::RepositoryState::Rebase | git2::RepositoryState::RebaseInteractive | git2::RepositoryState::RebaseMerge
    ));
    assert!(!r.fresh_index().has_conflicts());
    assert_eq!(r.head_oid(), c1, "HEAD should be at the edited commit, awaiting amend");
}

#[test]
fn edit_commit_then_continue_without_amend_completes() {
    let r = TestRepo::new();
    let _root = r.commit("root", "f.txt", "0");
    let c1 = r.commit("c1", "a.txt", "A");
    let _c2 = r.commit("c2", "b.txt", "B");

    let original_tree = r.head_tree_oid();

    let outcome = edit_commit(&r.path, &gc(), &s(c1)).unwrap();
    assert_eq!(outcome, RebaseOutcome::Paused { has_conflicts: false });

    let outcome = rebase_continue(&r.path, &gc()).unwrap();
    assert!(matches!(outcome, RebaseOutcome::Completed { .. }));
    assert_eq!(r.repo.state(), git2::RepositoryState::Clean);
    assert_eq!(r.head_tree_oid(), original_tree);
    assert_eq!(r.first_parent_chain_len(), 3);
    assert!(r.head_message().contains("c2"));
}

#[test]
fn edit_commit_then_amend_then_continue_keeps_amended_message() {
    let r = TestRepo::new();
    let _root = r.commit("root", "f.txt", "0");
    let c1 = r.commit("c1", "a.txt", "A");
    let _c2 = r.commit("c2", "b.txt", "B");

    let outcome = edit_commit(&r.path, &gc(), &s(c1)).unwrap();
    assert_eq!(outcome, RebaseOutcome::Paused { has_conflicts: false });

    let mut cmd = std::process::Command::new("git");
    cmd.current_dir(&r.path);
    cmd.env("LC_CTYPE", "C.UTF-8");
    cmd.env("GIT_EDITOR", "true");
    cmd.arg("-c").arg("commit.gpgsign=false");
    cmd.args(["commit", "--amend", "--allow-empty", "-m", "c1-amended"]);

    let status = cmd.status().unwrap();
    assert!(status.success());

    let outcome = rebase_continue(&r.path, &gc()).unwrap();
    assert!(matches!(outcome, RebaseOutcome::Completed { .. }));
    assert_eq!(r.repo.state(), git2::RepositoryState::Clean);

    let mut commit = r.repo.head().unwrap().peel_to_commit().unwrap();
    let mut found = false;

    for _ in 0..5 {
        if commit.message().unwrap_or("").contains("c1-amended") {
            found = true;
            break;
        }

        if commit.parent_count() == 0 {
            break;
        }

        commit = commit.parent(0).unwrap();
    }

    assert!(found, "amended message should appear in the rebased history");
}

#[test]
fn edit_commit_rejects_root_commit() {
    let r = TestRepo::new();
    let root = r.commit("root", "f.txt", "0");
    let _c1 = r.commit("c1", "a.txt", "A");

    let err = edit_commit(&r.path, &gc(), &s(root)).unwrap_err();
    assert!(err.contains("root commit"), "{err}");
}

#[test]
fn edit_commit_rejects_merge_commit() {
    let r = TestRepo::new();
    let base = r.commit("base", "f.txt", "0");

    r.create_branch_at("side", base);
    r.set_head_to_branch("side");

    let _side = r.commit("side", "s.txt", "S");
    let main = r.main_branch.clone();

    r.set_head_to_branch(&main);

    let _other = r.commit("other", "o.txt", "O");

    let merge_oid = {
        let main_tip = r.repo.head().unwrap().peel_to_commit().unwrap().id();

        let side_tip = r
            .repo
            .find_branch("side", git2::BranchType::Local)
            .unwrap()
            .into_reference()
            .peel_to_commit()
            .unwrap()
            .id();

        r.merge_commit("merge", &[main_tip, side_tip])
    };

    let err = edit_commit(&r.path, &gc(), &s(merge_oid)).unwrap_err();
    assert!(err.contains("merge"), "{err}");
}

#[test]
fn edit_commit_rejects_dirty_working_copy() {
    let r = TestRepo::new();
    let _root = r.commit("root", "f.txt", "0");
    let c1 = r.commit("c1", "a.txt", "A");

    r.write_file("a.txt", "modified");

    let err = edit_commit(&r.path, &gc(), &s(c1)).unwrap_err();
    assert!(err.contains("commit or stash"), "{err}");
}

#[test]
fn edit_commit_rejects_invalid_oid() {
    let r = TestRepo::new();
    let _c1 = r.commit("base", "a.txt", "A");

    let err = edit_commit(&r.path, &gc(), "not-a-valid-oid").unwrap_err();
    assert!(err.contains("Invalid OID"), "{err}");
}
