Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions crates/gitcomet-core/src/domain.rs
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,14 @@ pub struct RecentCommitMessage {
pub message: String,
}

/// A single-line summary of one commit in a ref range, used to preview the
/// commits a cherry-pick range would apply (oldest first, merges skipped).
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct CommitRefSummary {
pub id: CommitId,
pub summary: Arc<str>,
}

#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Hash)]
pub enum HistoryMode {
#[default]
Expand Down
34 changes: 34 additions & 0 deletions crates/gitcomet-core/src/services.rs
Original file line number Diff line number Diff line change
Expand Up @@ -683,6 +683,40 @@ pub trait GitRepository: Send + Sync {
}
fn revert(&self, id: &CommitId) -> Result<()>;

/// Creates a new branch `new_branch` pointing at `base`'s tip, checks it
/// out, and cherry-picks every commit reachable from `source` but not from
/// `range` (oldest first, merge commits skipped) onto it. `range` must be
/// an ancestor of `source`.
///
/// Errors without touching anything if `new_branch` already exists, the
/// range `range..source` is empty, or `range` is not an ancestor of
/// `source`. A cherry-pick conflict stops the sequence and leaves Git's
/// sequencer state in progress on `new_branch`, exactly like a regular
/// multi-commit cherry-pick.
fn cherry_pick_range_onto_new_branch(
&self,
_base: &str,
_range: &str,
_source: &str,
_new_branch: &str,
) -> Result<CommandOutput> {
Err(Error::new(ErrorKind::Unsupported(
"cherry-picking a branch range onto a new branch is not implemented for this backend",
)))
}
/// Lists the commits `range..source` would cherry-pick (oldest first,
/// merge commits skipped) as a preview. Errors when `range` is not an
/// ancestor of `source`.
fn cherry_pick_range_commits(
&self,
_range: &str,
_source: &str,
) -> Result<Vec<CommitRefSummary>> {
Err(Error::new(ErrorKind::Unsupported(
"listing a cherry-pick range is not implemented for this backend",
)))
}

fn stash_create(&self, message: &str, include_untracked: bool) -> Result<()>;
fn stash_list(&self) -> Result<Vec<StashEntry>>;
fn stash_list_cancellable(&self, cancellation: &CancellationToken) -> Result<Vec<StashEntry>> {
Expand Down
176 changes: 175 additions & 1 deletion crates/gitcomet-git-gix/src/repo/history.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ use crate::util::{
bytes_to_text_preserving_utf8, git_command_failed_error, run_git_capture, run_git_raw_output,
run_git_with_output, validate_hex_commit_id, validate_ref_like_arg,
};
use gitcomet_core::domain::CommitId;
use gitcomet_core::domain::{CommitId, CommitRefSummary};
use gitcomet_core::error::{Error, ErrorKind};
use gitcomet_core::services::{
CommandOutput, InteractiveRebaseAction, InteractiveRebaseEntry, ResetMode, Result,
Expand Down Expand Up @@ -1069,6 +1069,180 @@ impl GixRepo {
self.run_planned_rebase(entries, "HEAD", &label)
}

/// Creates `new_branch` at `base`'s tip, checks it out, and cherry-picks
/// every commit reachable from `source` but not from `range` (oldest
/// first, merge commits skipped) onto it. Nothing is created when the
/// range is empty, `range` is not an ancestor of `source`, or
/// `new_branch` already exists (`create_branch_impl` rejects it).
pub(super) fn cherry_pick_range_onto_new_branch_impl(
&self,
base: &str,
range: &str,
source: &str,
new_branch: &str,
) -> Result<CommandOutput> {
validate_ref_like_arg(base, "base branch name")?;
validate_ref_like_arg(range, "range reference")?;
validate_ref_like_arg(source, "source reference")?;
validate_ref_like_arg(new_branch, "new branch name")?;

// `range` must be an ancestor of `source` for `range..source` to be a
// meaningful commit set; git's --is-ancestor exits 1 when it is not.
let mut cmd = self.git_workdir_cmd();
cmd.arg("merge-base")
.arg("--is-ancestor")
.arg(range)
.arg(source);
let ancestor_label = format!("git merge-base --is-ancestor {range} {source}");
match run_git_raw_output(cmd, &ancestor_label) {
Ok(output) if output.status.success() => {}
Ok(output) if output.status.code() == Some(1) => {
return Err(Error::new(ErrorKind::Backend(format!(
"{range} is not an ancestor of {source}; the range {range}..{source} would \
include unrelated history — pick a range reference that is an ancestor"
))));
}
Ok(output) => {
return Err(Error::new(ErrorKind::Backend(format!(
"failed to check whether {range} is an ancestor of {source}: {}",
bytes_to_text_preserving_utf8(&output.stderr).trim()
))));
}
Err(e) => {
return Err(Error::new(ErrorKind::Backend(format!(
"failed to check whether {range} is an ancestor of {source}: {e}"
))));
}
}

// Oldest-first, merge commits skipped: the same set `git cherry-pick
// range..source` would apply, enumerated explicitly so an empty range
// is rejected before any branch or checkout is created.
let mut cmd = self.git_workdir_cmd();
cmd.arg("rev-list")
.arg("--reverse")
.arg("--no-merges")
.arg(format!("{range}..{source}"));
let rev_list_label = format!("git rev-list --reverse --no-merges {range}..{source}");
let output = run_git_raw_output(cmd, &rev_list_label).map_err(|e| {
Error::new(ErrorKind::Backend(format!(
"failed to list commits in {range}..{source}: {e}"
)))
})?;
let shas: Vec<String> = bytes_to_text_preserving_utf8(&output.stdout)
.lines()
.map(str::trim)
.filter(|line| !line.is_empty())
.map(str::to_string)
.collect();
if shas.is_empty() {
return Err(Error::new(ErrorKind::Backend(format!(
"no commits to cherry-pick: {source} has no commits that are not already in \
{range}"
))));
}

// Create the branch from `base` (errors if it already exists) and
// move onto it before the picks, mirroring create-branch-and-checkout.
self.create_branch_impl(new_branch, &CommitId(base.into()))?;
self.checkout_branch_impl(new_branch)?;

let mut cmd = self.git_workdir_cmd();
cmd.arg("cherry-pick")
.arg("--no-edit")
.arg("--allow-empty")
.arg("--");
for sha in &shas {
cmd.arg(sha);
}
let label = format!("git cherry-pick {} commits onto {new_branch}", shas.len());
// An already-applied commit stops the whole sequence with an "empty"
// error that only `--skip` moves past, and the UI exposes no skip
// control — advance automatically so the remaining picks land.
self.run_cherry_pick_step_output(cmd, &label)
}

/// Lists `range..source` commits (oldest first, merge commits skipped) for
/// the cherry-pick preview. Rejects a `range` that is not an ancestor of
/// `source` so the preview never shows diverged history.
pub(super) fn cherry_pick_range_commits_impl(
&self,
range: &str,
source: &str,
) -> Result<Vec<CommitRefSummary>> {
validate_ref_like_arg(range, "range reference")?;
validate_ref_like_arg(source, "source reference")?;

let mut cmd = self.git_workdir_cmd();
cmd.arg("merge-base")
.arg("--is-ancestor")
.arg(range)
.arg(source);
let ancestor_label = format!("git merge-base --is-ancestor {range} {source}");
match run_git_raw_output(cmd, &ancestor_label) {
Ok(output) if output.status.success() => {}
Ok(output) if output.status.code() == Some(1) => {
return Err(Error::new(ErrorKind::Backend(format!(
"{range} is not an ancestor of {source}; the range {range}..{source} would \
include unrelated history — pick a range reference that is an ancestor"
))));
}
Ok(output) => {
return Err(Error::new(ErrorKind::Backend(format!(
"failed to check whether {range} is an ancestor of {source}: {}",
bytes_to_text_preserving_utf8(&output.stderr).trim()
))));
}
Err(e) => {
return Err(Error::new(ErrorKind::Backend(format!(
"failed to check whether {range} is an ancestor of {source}: {e}"
))));
}
}

let range_arg = format!("{range}..{source}");
let mut cmd = self.git_workdir_cmd();
// NUL-framed sha + single-line subject records (`-z`), oldest first,
// merges skipped: the exact set the range cherry-pick applies.
cmd.args([
"log",
"-z",
"--format=%H%x00%s",
"--reverse",
"--topo-order",
"--no-merges",
&range_arg,
]);
let output = run_git_capture(cmd, &format!("git log {range_arg}"))?;
let mut fields: Vec<&str> = output.split('\0').collect();
if fields.last() == Some(&"") {
fields.pop();
}
if !fields.len().is_multiple_of(2) {
return Err(Error::new(ErrorKind::Backend(
"unexpected git log output while listing the cherry-pick range".to_string(),
)));
}
fields
.chunks_exact(2)
.map(|record| {
let (sha, summary) = (record[0], record[1]);
let full_hex_id = (sha.len() == 40 || sha.len() == 64)
&& sha.bytes().all(|b| b.is_ascii_hexdigit());
if !full_hex_id {
return Err(Error::new(ErrorKind::Backend(format!(
"unexpected commit id {sha:?} in git log output while listing the \
cherry-pick range"
))));
}
Ok(CommitRefSummary {
id: CommitId(sha.into()),
summary: summary.into(),
})
})
.collect()
}

pub(super) fn merge_commit_message_impl(&self) -> Result<Option<String>> {
let repo = self._repo.to_thread_local();
if repo.state() != Some(gix::state::InProgress::Merge) {
Expand Down
27 changes: 23 additions & 4 deletions crates/gitcomet-git-gix/src/repo/mod.rs
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
use crate::util::git_workdir_cmd_for as util_git_workdir_cmd_for;
use gitcomet_core::conflict_session::ConflictSession;
use gitcomet_core::domain::{
Branch, Commit, CommitDetails, CommitFileChange, CommitId, Diff, DiffArea, DiffPreviewTextSide,
DiffTarget, FileDiffImage, FileDiffText, FileEntry, HistoryMode, LogCursor, LogPage,
RecentCommitMessage, RefMetadata, ReflogEntry, Remote, RemoteBranch, RemoteTag, RepoSpec,
RepoStatus, StashEntry, Submodule, SubmoduleDiffSummary, Tag, UpstreamDivergence, Worktree,
Branch, Commit, CommitDetails, CommitFileChange, CommitId, CommitRefSummary, Diff, DiffArea,
DiffPreviewTextSide, DiffTarget, FileDiffImage, FileDiffText, FileEntry, HistoryMode,
LogCursor, LogPage, RecentCommitMessage, RefMetadata, ReflogEntry, Remote, RemoteBranch,
RemoteTag, RepoSpec, RepoStatus, StashEntry, Submodule, SubmoduleDiffSummary, Tag,
UpstreamDivergence, Worktree,
};
use gitcomet_core::error::{Error, ErrorKind};
use gitcomet_core::git_ops_trace::{self, GitOpTraceKind};
Expand Down Expand Up @@ -607,6 +608,24 @@ impl GitRepository for GixRepo {
self.revert_impl(id)
}

fn cherry_pick_range_onto_new_branch(
&self,
base: &str,
range: &str,
source: &str,
new_branch: &str,
) -> Result<CommandOutput> {
self.cherry_pick_range_onto_new_branch_impl(base, range, source, new_branch)
}

fn cherry_pick_range_commits(
&self,
range: &str,
source: &str,
) -> Result<Vec<CommitRefSummary>> {
self.cherry_pick_range_commits_impl(range, source)
}

fn stash_create(&self, message: &str, include_untracked: bool) -> Result<()> {
self.stash_create_impl(message, include_untracked)
}
Expand Down
Loading