Skip to content
Draft
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
2 changes: 2 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

10 changes: 10 additions & 0 deletions git-branchless-lib/src/core/rewrite/execute.rs
Original file line number Diff line number Diff line change
Expand Up @@ -327,6 +327,10 @@ pub enum MergeConflictRemediation {

/// Indicate that the user should run `git move -m -s 'siblings(.)'`.
Insert,

// TODO: confirm this message
/// Indicate that the user should run `git move -m -x HEAD~ --onto HEAD`.
Before,
}

/// Information about a failure to merge that occurred while moving commits.
Expand Down Expand Up @@ -415,6 +419,12 @@ impl FailedMergeInfo {
"To resolve merge conflicts, run: git move -m -s 'siblings(.)'"
)?;
}
MergeConflictRemediation::Before => {
writeln!(
effects.get_output_stream(),
"To resolve merge conflicts, run: git move -m -x HEAD~ --onto HEAD"
)?;
}
}

Ok(())
Expand Down
27 changes: 21 additions & 6 deletions git-branchless-lib/src/git/diff.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,15 @@ pub struct Diff<'repo> {
}

impl Diff<'_> {
/// Enable rename and copy detection for this diff.
///
/// Must be called before iterating deltas if you want renames to be
/// reported as a single `Renamed` delta rather than separate add/delete
/// deltas.
pub fn find_similar(&mut self) -> eyre::Result<()> {
self.inner.find_similar(None).map_err(|e| eyre::eyre!(e))
}

/// Summarize this diff into a single line "short" format.
pub fn short_stats(&self) -> eyre::Result<String> {
let stats = self.inner.stats()?;
Expand Down Expand Up @@ -48,11 +57,15 @@ pub fn summarize_diff_for_temporary_commit(diff: &Diff) -> eyre::Result<String>
// returns an Err, so catch and ignore it
let _ = diff.inner.foreach(
&mut |delta: git2::DiffDelta, _| {
let relevant_path = delta
.old_file()
.path()
.or(delta.new_file().path())
.unwrap_or_else(|| unreachable!("diff should have contained at least 1 file"));
// For deletions show the old (removed) path; for everything
// else (including renames) prefer the new path so that e.g.
// a rename "foo.txt → bar.txt" is labelled as "bar.txt".
let relevant_path = if delta.status() == git2::Delta::Deleted {
delta.old_file().path().or_else(|| delta.new_file().path())
} else {
delta.new_file().path().or_else(|| delta.old_file().path())
}
.unwrap_or_else(|| unreachable!("diff should have contained at least 1 file"));
filename = Some(format!("{}", relevant_path.display()));
false
},
Expand All @@ -67,7 +80,9 @@ pub fn summarize_diff_for_temporary_commit(diff: &Diff) -> eyre::Result<String>
};

let ins_del = match (stats.insertions(), stats.deletions()) {
(0, 0) => unreachable!("empty diff"),
// A diff with no insertions or deletions still has a file change,
// e.g. a pure rename or a file-mode change.
(0, 0) => "0".to_string(),
(i, 0) => format!("+{i}"),
(0, d) => format!("-{d}"),
(i, d) => format!("+{i}/-{d}"),
Expand Down
4 changes: 2 additions & 2 deletions git-branchless-lib/src/git/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,8 @@ pub use reference::{
};
pub use repo::{
AmendFastOptions, CherryPickFastOptions, CreateCommitFastError, Error as RepoError,
GitErrorCode, GitVersion, PatchId, Repo, ResolvedReferenceInfo, Result as RepoResult, Time,
message_prettify,
GitErrorCode, GitVersion, PatchId, Repo, ResolvedReferenceInfo, Result as RepoResult,
Signature, Time, message_prettify,
};
pub use run::{GitRunInfo, GitRunOpts, GitRunResult};
pub use snapshot::{WorkingCopyChangesType, WorkingCopySnapshot};
Expand Down
14 changes: 14 additions & 0 deletions git-branchless-lib/src/git/repo.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1703,6 +1703,18 @@ impl std::fmt::Debug for Signature<'_> {
}

impl<'repo> Signature<'repo> {
/// Create a new signature.
#[instrument]
pub fn new(name: &str, email: &str, now: &SystemTime) -> Result<Self> {
Ok({
Signature {
inner: git2::Signature::now(name, email).map_err(Error::CreateSignature)?,
}
.update_timestamp(*now)?
})
}

/// Create an automated signature, for internal use.
#[instrument]
pub fn automated() -> Result<Self> {
Ok(Signature {
Expand Down Expand Up @@ -1752,10 +1764,12 @@ impl<'repo> Signature<'repo> {
}
}

/// Get the name applied to this signature.
pub fn get_name(&self) -> Option<&str> {
self.inner.name()
}

/// Get the email applied to this signature.
pub fn get_email(&self) -> Option<&str> {
self.inner.email()
}
Expand Down
13 changes: 11 additions & 2 deletions git-branchless-lib/src/testing.rs
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ fn try_find_cargo_bin(name: &str) -> Option<PathBuf> {

const DUMMY_NAME: &str = "Testy McTestface";
const DUMMY_EMAIL: &str = "test@example.com";
const DUMMY_DATE: &str = "Wed 29 Oct 12:34:56 2020 PDT";
const DUMMY_DATE: &str = "Thu, 29 Oct 2020 12:34:56";

/// Wrapper around the Git executable, for testing.
#[derive(Clone, Debug)]
Expand Down Expand Up @@ -197,7 +197,7 @@ impl Git {
pub fn get_base_env(&self, time: isize) -> Vec<(OsString, OsString)> {
// Required for determinism, as these values will be baked into the commit
// hash.
let date: OsString = format!("{DUMMY_DATE} -{time:0>2}").into();
let date: OsString = format!("{DUMMY_DATE} -{time:0>2}00").into();

// Fake "editor" which accepts the default contents of any commit
// messages. Usually, we can set this with `git commit -m`, but we have
Expand All @@ -207,15 +207,24 @@ impl Git {
// ":" is understood by `git` to skip editing.
let git_editor = OsString::from(":");

// Set timezone to avoid snapshot conflicts between local machines in
// different timezones, and CI. This may only affect places where we
// create wholely new commits, eg `record --new`.
let git_timezone = OsString::from("UTC");

let new_path = self.get_path_for_env();
let envs = vec![
("GIT_CONFIG_NOSYSTEM", OsString::from("1")),
// Note that git also supports a GIT_TEST_DATE_NOW env var, which
// may come in useful in the future:
// https://github.com/git/git/blob/7bcaabddcf68bd0702697da5904c3b68c52f94cf/date.c#L128
("GIT_AUTHOR_DATE", date.clone()),
("GIT_COMMITTER_DATE", date),
("GIT_EDITOR", git_editor),
("GIT_EXEC_PATH", self.git_exec_path.as_os_str().into()),
("LC_ALL", "C".into()),
("PATH", new_path),
("TZ", git_timezone),
(TEST_GIT, self.path_to_git.as_os_str().into()),
(
TEST_SEPARATE_COMMAND_BINARIES,
Expand Down
130 changes: 128 additions & 2 deletions git-branchless-opts/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,116 @@ impl Display for Revset {
}
}

/// Specifies changes to extract from a commit during a split operation.
///
/// May be one of:
/// - A file path (e.g. `src/main.rs`): extract all changes to that file.
/// - A file path with a single line number (e.g. `src/main.rs:42`): extract
/// only the hunk(s) that touch line 42 in the new (target) version of the
/// file.
/// - A file path with a line range (e.g. `src/main.rs:10-42`): extract all
/// hunks that overlap lines 10–42 in the new version of the file.
///
/// Line numbers are 1-indexed and refer to the file *after* the commit
/// (i.e. in the target tree).
#[derive(Clone, Debug)]
pub enum FileExtractSpec {
/// Extract all changes to the file.
WholeFile(String),
/// Extract only the changes in the specified line range.
LineRange {
/// The raw file string as given on the command line.
file: String,
/// First line of the range (1-indexed, inclusive).
start_line: usize,
/// Last line of the range (1-indexed, inclusive).
end_line: usize,
},
}

impl FileExtractSpec {
/// Returns the raw file string as provided on the command line.
pub fn file_str(&self) -> &str {
match self {
Self::WholeFile(s) | Self::LineRange { file: s, .. } => s,
}
}
}

fn parse_line_range_suffix(s: &str) -> Option<(usize, usize)> {
if let Some((start_str, end_str)) = s.split_once('-') {
let start = start_str.parse::<usize>().ok().filter(|&n| n > 0)?;
let end = end_str.parse::<usize>().ok().filter(|&n| n >= start)?;
Some((start, end))
} else {
let line = s.parse::<usize>().ok().filter(|&n| n > 0)?;
Some((line, line))
}
}

impl FromStr for FileExtractSpec {
type Err = String;

fn from_str(s: &str) -> Result<Self, Self::Err> {
let split: Vec<&str> = s.split(':').collect();
// TODO: is this exhaustive of all options, esp considering windows?
// TODO: consider using #[cfg()] for the windows logic?
let (file_part, maybe_range_part) = match split.as_slice() {
// empty split (impossible)
[] => unreachable!(),
// path
[_] => (s.to_owned(), None),
// :/path
["", _] => (s.to_owned(), None),
// path:range or C:\path
[one, two] => (one.to_string(), Some(two.to_string())),
// :/path:range
["", .., three] => {
let colon_pos = three.len() + 1;
let file_part = &s[..colon_pos];
(file_part.to_owned(), Some(three.to_string()))
}
// C:\path:range
[_, .., three] => {
let colon_pos = three.len() + 1;
let file_part = &s[..colon_pos];
(file_part.to_owned(), Some(three.to_string()))
}
};

if let Some(range_part) = maybe_range_part {
if let Some((start, end)) = parse_line_range_suffix(&range_part) {
return Ok(Self::LineRange {
file: file_part.to_owned(),
start_line: start,
end_line: end,
});
}
}

Ok(Self::WholeFile(s.to_owned()))
}
}

impl Display for FileExtractSpec {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::WholeFile(file) => write!(f, "{file}"),
Self::LineRange {
file,
start_line,
end_line,
} => {
if start_line == end_line {
write!(f, "{file}:{start_line}")
} else {
write!(f, "{file}:{start_line}-{end_line}")
}
}
}
}
}

/// A command wrapped by `git-branchless wrap`. The arguments are forwarded to
/// `git`.
#[derive(Debug, Parser)]
Expand Down Expand Up @@ -347,10 +457,19 @@ pub struct RecordArgs {
#[clap(action, short = 'I', long = "insert")]
pub insert: bool,

/// Insert the new commit before HEAD, as a child of HEAD~, then rebase
/// HEAD onto the new commit.
#[clap(action, long = "before", conflicts_with("insert"))]
pub before: bool,

/// After making the new commit, switch back to the previous commit.
#[clap(action, short = 's', long = "stash", conflicts_with_all(&["create", "detach"]))]
pub stash: bool,

/// Create an empty commit, leaving any changes uncommitted.
#[clap(action, long = "new", conflicts_with("stash"))]
pub new: bool,

/// How should newly encountered, untracked files be handled?
#[clap(value_parser, long = "untracked", conflicts_with_all(&["interactive"]))]
pub untracked_file_strategy: Option<UntrackedFileStrategy>,
Expand Down Expand Up @@ -679,9 +798,16 @@ pub enum Command {
#[clap(value_parser)]
revset: Revset,

/// Files to extract from the commit.
/// Files (or file+line-range specs) to extract from the commit.
///
/// Each argument may be:
/// - A file path (e.g. `src/main.rs`) to extract all changes to that file.
/// - A file path with a single line number (e.g. `src/main.rs:42`) to
/// extract only the hunk(s) touching that line in the committed file.
/// - A file path with a line range (e.g. `src/main.rs:10-42`) to extract
/// all hunks overlapping those lines in the committed file.
#[clap(value_parser, required = true)]
files: Vec<String>,
files: Vec<FileExtractSpec>,

/// Insert the extracted commit before (as a parent of) the split commit.
#[clap(action, short = 'b', long)]
Expand Down
1 change: 1 addition & 0 deletions git-branchless-record/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ repository = "https://github.com/arxanas/git-branchless"
version = "0.11.0"

[dependencies]
chrono = { workspace = true }
cursive = { version = "0.21.1", default-features = false, features = [
"crossterm-backend",
] }
Expand Down
Loading
Loading