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
/// Perform a rebase onto the specified upstream branch.
///
/// This function rebases the current branch onto the specified upstream branch.
/// It handles the full rebase process including conflict detection and
/// classifies all known failure modes.
///
/// # Arguments
///
/// * `upstream_branch` - The branch to rebase onto (e.g., "main", "origin/main")
/// * `executor` - Process executor for dependency injection
///
/// # Returns
///
/// Returns `Ok(RebaseResult)` indicating the outcome, or an error if:
/// - The repository cannot be opened
/// - The rebase operation fails in an unexpected way
///
/// # Edge Cases Handled
///
/// - Empty repository (no commits) - Returns `Ok(RebaseResult::NoOp)` with reason
/// - Unborn branch - Returns `Ok(RebaseResult::NoOp)` with reason
/// - Already up-to-date - Returns `Ok(RebaseResult::NoOp)` with reason
/// - Unrelated branches (no shared ancestor) - Returns `Ok(RebaseResult::NoOp)` with reason
/// - On main/master branch - Returns `Ok(RebaseResult::NoOp)` with reason
/// - Conflicts during rebase - Returns `Ok(RebaseResult::Conflicts)` or `Failed` with error kind
/// - Other failures - Returns `Ok(RebaseResult::Failed)` with appropriate error kind
///
/// # Note
///
/// This function uses git CLI for rebase operations as libgit2's rebase API
/// has limitations and complexity that make it unreliable for production use.
/// The git CLI is more robust and better tested for rebase operations.
///
///
/// # Errors
///
/// Returns error if the operation fails.
pub fn rebase_onto(
upstream_branch: &str,
executor: &dyn crate::executor::ProcessExecutor,
) -> io::Result<RebaseResult> {
let repo = git2::Repository::discover(".").map_err(|e| git2_to_io_error(&e))?;
rebase_onto_impl(&repo, upstream_branch, executor)
}
/// Implementation of `rebase_onto`.
fn rebase_onto_impl(
repo: &git2::Repository,
upstream_branch: &str,
executor: &dyn crate::executor::ProcessExecutor,
) -> io::Result<RebaseResult> {
// Check if we have any commits
match repo.head() {
Ok(_) => {}
Err(ref e) if e.code() == git2::ErrorCode::UnbornBranch => {
// No commits yet - nothing to rebase
return Ok(RebaseResult::NoOp {
reason: "Repository has no commits yet (unborn branch)".to_string(),
});
}
Err(e) => return Err(git2_to_io_error(&e)),
}
// Get the upstream branch to ensure it exists
let Ok(upstream_object) = repo.revparse_single(upstream_branch) else {
return Ok(RebaseResult::Failed(RebaseErrorKind::InvalidRevision {
revision: upstream_branch.to_string(),
}));
};
let upstream_commit = upstream_object
.peel_to_commit()
.map_err(|e| git2_to_io_error(&e))?;
// Get our branch commit
let head = repo.head().map_err(|e| git2_to_io_error(&e))?;
let head_commit = head.peel_to_commit().map_err(|e| git2_to_io_error(&e))?;
// Check if we're already up-to-date
if repo
.graph_descendant_of(head_commit.id(), upstream_commit.id())
.map_err(|e| git2_to_io_error(&e))?
{
// Already up-to-date
return Ok(RebaseResult::NoOp {
reason: "Branch is already up-to-date with upstream".to_string(),
});
}
// Check if branches share a common ancestor
// If merge_base fails with NotFound, branches are unrelated
match repo.merge_base(head_commit.id(), upstream_commit.id()) {
Err(e)
if e.class() == git2::ErrorClass::Reference
&& e.code() == git2::ErrorCode::NotFound =>
{
// Branches are unrelated - no shared history
return Ok(RebaseResult::NoOp {
reason: format!(
"No common ancestor between current branch and '{upstream_branch}' (unrelated branches)"
),
});
}
Err(e) => return Err(git2_to_io_error(&e)),
Ok(_) => {}
}
// Check if we're on main/master or in a detached HEAD state
let Some(branch_name) = head.shorthand() else {
// Detached HEAD state - rebase is not applicable
return Ok(RebaseResult::NoOp {
reason: "HEAD is detached (not on any branch), rebase not applicable".to_string(),
});
};
if branch_name == "main" || branch_name == "master" {
return Ok(RebaseResult::NoOp {
reason: format!("Already on '{branch_name}' branch, rebase not applicable"),
});
}
// Use git CLI for rebase via executor - more reliable than libgit2
let output = executor.execute("git", &["rebase", upstream_branch], &[], None)?;
let is_success = output.succeeded();
if is_success {
Ok(RebaseResult::Success)
} else {
let stderr = &output.stderr;
let stdout = &output.stdout;
// Use classify_rebase_error to determine specific failure mode
let error_kind = classify_rebase_error(stderr, stdout);
Ok(classify_rebase_result(error_kind, stderr))
}
}
fn classify_rebase_result(error_kind: RebaseErrorKind, stderr: &str) -> RebaseResult {
match error_kind {
RebaseErrorKind::ContentConflict { .. } => match get_conflicted_files() {
Ok(files) if files.is_empty() => {
if let RebaseErrorKind::ContentConflict { files } = error_kind {
RebaseResult::Conflicts(files)
} else {
RebaseResult::Conflicts(vec![])
}
}
Ok(files) => RebaseResult::Conflicts(files),
Err(_) => RebaseResult::Conflicts(vec![]),
},
RebaseErrorKind::Unknown { .. } => {
if stderr.contains("up to date") {
RebaseResult::NoOp {
reason: "Branch is already up-to-date with upstream".to_string(),
}
} else {
RebaseResult::Failed(error_kind)
}
}
_ => RebaseResult::Failed(error_kind),
}
}