pax_global_header00006660000000000000000000000064145461274340014524gustar00rootroot0000000000000052 comment=d2895932847fe97bc59407e6f6ad862caf358f40 git-autofixup-0.004006/000077500000000000000000000000001454612743400145625ustar00rootroot00000000000000git-autofixup-0.004006/.github/000077500000000000000000000000001454612743400161225ustar00rootroot00000000000000git-autofixup-0.004006/.github/workflows/000077500000000000000000000000001454612743400201575ustar00rootroot00000000000000git-autofixup-0.004006/.github/workflows/ci.yml000066400000000000000000000005661454612743400213040ustar00rootroot00000000000000on: [push, pull_request] jobs: ci: runs-on: ubuntu-latest steps: - uses: actions/checkout@v2 - name: Install dependencies run: sudo apt install perl-doc - name: Make sure README.pod is up to date run: perldoc -u git-autofixup >README.pod && git diff --exit-code - name: Run tests run: perl Makefile.PL && make test git-autofixup-0.004006/.gitignore000066400000000000000000000001021454612743400165430ustar00rootroot00000000000000*.bak MYMETA.* META.yml App-Git-Autofixup-*.tar.gz Makefile /todo git-autofixup-0.004006/Changes000066400000000000000000000105711454612743400160610ustar00rootroot00000000000000# 0.004006 - Add core perl modules as dependencies, mostly so App::Git::Autofixup is easier to install via cpan when Test::More isn't installed. Perl installations don't always include all the core modules. - Fix tempdir cleanup in tests # 0.004005 - Fix --help: pod2usage() wasn't being called correctly # 0.004004 - Skip capture() tests on Windows. - Skip fork-point test on older git versions where merge-base doesn't have fork-point mode # 0.004003 Add missing files to package manifest. # 0.004002 Fix minor issues so git-autofixup works with Git for Windows: - close index temp file so git can write to it. (Windows has different file locking behaviour than *nix.) - load Pod::Usage at runtime since Git for Windows doesn't include it and has special handling for `git --help` such that it wouldn't get used anyway unless git-autofixup was invoked directly. Fix some test failures: - skip tests when perl is for Cygwin but git is for MSYS - skip testing backslashes on Windows (Cygwin|MSYS) - fallback to --set-upstream for old git versions where --set-upstream-to isn't available # 0.004001 - Fix test plans so Test::More::plan() doesn't get called twice when git isn't available. # 0.004000 - Automatically choose an upstream revision if one isn't supplied, based on the upstream/tracking branch. Thanks to Walter Smuts and Johannes Altmanninger for their help in figuring out the details. - Support quoted filenames in diff output. git-autofixup now works with filenames containing non-ASCII characters. - Improve error messages and handling. For git commands that are expected to fail, their stderr is captured, annotated with the command, and printed, to clarify the cause of errors. - Deprecate --gitopt|-g in favor of using the GIT_CONFIG_{COUNT,KEY,VALUE} environment variables. # 0.003002 - Speed up creation of temporary git index by copying the existing one and subtracting recent changes - Speed up `git-blame` by only considering commits since the given revision - Handle filenames (in git diff output) that contain spaces - Suppress Git warning about implicit default branch Many thanks to Johannes Altmanninger for his continued work; he implemented or contributed to all the important changes in this release. # 0.003001 - Fix bug where the index would be left out-of-sync with `HEAD` after autofixing unstaged hunks due to a temporary index being used. If you're running v0.003000 and hit this, `git restore --staged` can be used to read the new `HEAD`'s tree into the index. Thanks to Johannes Altmanninger for finding and fixing this. # 0.003000 The most important change to the interface is that now, if there are any hunks staged in the index, only those hunks will be considered for assigning to fixup commits. A temporary git index is used to make any created fixup commits, so any staged hunks that don't get assigned will remain staged. Thanks to Jonas Bernoulli and Max Odnoletkov for their help with this. - Add --gitopt to allow working around git settings issues - Add --exit-code option, which gives more granular status about what subset of hunks were assigned to commits Bug fixes: - Fix diff commands so that the diff.noprefix, diff.mnemonicPrefix, and diff.external settings don't result in us getting unexpected input. Thanks to Paolo Giarrusso and Ryan Campbell for help with this. - Fix bug where multiple hunks assigned to the same commit would result in copies of the same fixup commit, resulting in "patch does not apply" errors. Thanks to Johannes Altmanninger for identifying and fixing this. # 0.002007 - Fix hunk parsing for files with multiple hunks (broken in 0.002006) - Create fixup commits in a consistent order # 0.002006 - Improve docs for the --strict option - Fix hunk parsing for files without a newline at EOF # 0.002005 - Fix running from repo subdirectories - Fix docs for invoking as "git autofixup" # 0.002004 - Fix angle brackets in POD # 0.002003 - Check git version when running tests # 0.002002 - Fix tests when git user.name and email aren't configured. # 0.002001 - Make compatible with perl 5.8.4. Previously 5.8.9 was required. # 0.002000 - Better descriptions of hunk handling now printed to stdout. - Use --strict=1 behaviour as a fallback when --strict=0. - Fix blamed line number and left side output for runs of added lines # 0.001002 - Make compatible with perl5.8 - --help now shows the manpage git-autofixup-0.004006/LICENSE000066400000000000000000000215231454612743400155720ustar00rootroot00000000000000This software is Copyright (c) 2017 by Jordan Torbiak. This is free software, licensed under: The Artistic License 2.0 (GPL Compatible) The Artistic License 2.0 Copyright (c) 2000-2006, The Perl Foundation. Everyone is permitted to copy and distribute verbatim copies of this license document, but changing it is not allowed. Preamble This license establishes the terms under which a given free software Package may be copied, modified, distributed, and/or redistributed. The intent is that the Copyright Holder maintains some artistic control over the development of that Package while still keeping the Package available as open source and free software. You are always permitted to make arrangements wholly outside of this license directly with the Copyright Holder of a given Package. If the terms of this license do not permit the full use that you propose to make of the Package, you should contact the Copyright Holder and seek a different licensing arrangement. Definitions "Copyright Holder" means the individual(s) or organization(s) named in the copyright notice for the entire Package. "Contributor" means any party that has contributed code or other material to the Package, in accordance with the Copyright Holder's procedures. "You" and "your" means any person who would like to copy, distribute, or modify the Package. "Package" means the collection of files distributed by the Copyright Holder, and derivatives of that collection and/or of those files. A given Package may consist of either the Standard Version, or a Modified Version. "Distribute" means providing a copy of the Package or making it accessible to anyone else, or in the case of a company or organization, to others outside of your company or organization. "Distributor Fee" means any fee that you charge for Distributing this Package or providing support for this Package to another party. It does not mean licensing fees. "Standard Version" refers to the Package if it has not been modified, or has been modified only in ways explicitly requested by the Copyright Holder. "Modified Version" means the Package, if it has been changed, and such changes were not explicitly requested by the Copyright Holder. "Original License" means this Artistic License as Distributed with the Standard Version of the Package, in its current version or as it may be modified by The Perl Foundation in the future. "Source" form means the source code, documentation source, and configuration files for the Package. "Compiled" form means the compiled bytecode, object code, binary, or any other form resulting from mechanical transformation or translation of the Source form. Permission for Use and Modification Without Distribution (1) You are permitted to use the Standard Version and create and use Modified Versions for any purpose without restriction, provided that you do not Distribute the Modified Version. Permissions for Redistribution of the Standard Version (2) You may Distribute verbatim copies of the Source form of the Standard Version of this Package in any medium without restriction, either gratis or for a Distributor Fee, provided that you duplicate all of the original copyright notices and associated disclaimers. At your discretion, such verbatim copies may or may not include a Compiled form of the Package. (3) You may apply any bug fixes, portability changes, and other modifications made available from the Copyright Holder. The resulting Package will still be considered the Standard Version, and as such will be subject to the Original License. Distribution of Modified Versions of the Package as Source (4) You may Distribute your Modified Version as Source (either gratis or for a Distributor Fee, and with or without a Compiled form of the Modified Version) provided that you clearly document how it differs from the Standard Version, including, but not limited to, documenting any non-standard features, executables, or modules, and provided that you do at least ONE of the following: (a) make the Modified Version available to the Copyright Holder of the Standard Version, under the Original License, so that the Copyright Holder may include your modifications in the Standard Version. (b) ensure that installation of your Modified Version does not prevent the user installing or running the Standard Version. In addition, the Modified Version must bear a name that is different from the name of the Standard Version. (c) allow anyone who receives a copy of the Modified Version to make the Source form of the Modified Version available to others under (i) the Original License or (ii) a license that permits the licensee to freely copy, modify and redistribute the Modified Version using the same licensing terms that apply to the copy that the licensee received, and requires that the Source form of the Modified Version, and of any works derived from it, be made freely available in that license fees are prohibited but Distributor Fees are allowed. Distribution of Compiled Forms of the Standard Version or Modified Versions without the Source (5) You may Distribute Compiled forms of the Standard Version without the Source, provided that you include complete instructions on how to get the Source of the Standard Version. Such instructions must be valid at the time of your distribution. If these instructions, at any time while you are carrying out such distribution, become invalid, you must provide new instructions on demand or cease further distribution. If you provide valid instructions or cease distribution within thirty days after you become aware that the instructions are invalid, then you do not forfeit any of your rights under this license. (6) You may Distribute a Modified Version in Compiled form without the Source, provided that you comply with Section 4 with respect to the Source of the Modified Version. Aggregating or Linking the Package (7) You may aggregate the Package (either the Standard Version or Modified Version) with other packages and Distribute the resulting aggregation provided that you do not charge a licensing fee for the Package. Distributor Fees are permitted, and licensing fees for other components in the aggregation are permitted. The terms of this license apply to the use and Distribution of the Standard or Modified Versions as included in the aggregation. (8) You are permitted to link Modified and Standard Versions with other works, to embed the Package in a larger work of your own, or to build stand-alone binary or bytecode versions of applications that include the Package, and Distribute the result without restriction, provided the result does not expose a direct interface to the Package. Items That are Not Considered Part of a Modified Version (9) Works (including, but not limited to, modules and scripts) that merely extend or make use of the Package, do not, by themselves, cause the Package to be a Modified Version. In addition, such works are not considered parts of the Package itself, and are not subject to the terms of this license. General Provisions (10) Any use, modification, and distribution of the Standard or Modified Versions is governed by this Artistic License. By using, modifying or distributing the Package, you accept this license. Do not use, modify, or distribute the Package, if you do not accept this license. (11) If your Modified Version has been derived from a Modified Version made by someone other than you, you are nevertheless required to ensure that your Modified Version complies with the requirements of this license. (12) This license does not grant you the right to use any trademark, service mark, tradename, or logo of the Copyright Holder. (13) This license includes the non-exclusive, worldwide, free-of-charge patent license to make, have made, use, offer to sell, sell, import and otherwise transfer the Package with respect to any patent claims licensable by the Copyright Holder that are necessarily infringed by the Package. If you institute patent litigation (including a cross-claim or counterclaim) against any party alleging that the Package constitutes direct or contributory patent infringement, then this Artistic License to you shall terminate on the date that such litigation is filed. (14) Disclaimer of Warranty: THE PACKAGE IS PROVIDED BY THE COPYRIGHT HOLDER AND CONTRIBUTORS "AS IS" AND WITHOUT ANY EXPRESS OR IMPLIED WARRANTIES. THE IMPLIED WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, OR NON-INFRINGEMENT ARE DISCLAIMED TO THE EXTENT PERMITTED BY YOUR LOCAL LAW. UNLESS REQUIRED BY LAW, NO COPYRIGHT HOLDER OR CONTRIBUTOR WILL BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL DAMAGES ARISING IN ANY WAY OUT OF THE USE OF THE PACKAGE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. git-autofixup-0.004006/MANIFEST000066400000000000000000000002561454612743400157160ustar00rootroot00000000000000Changes git-autofixup lib/App/Git/Autofixup.pm LICENSE Makefile.PL MANIFEST META.yml README.pod t/autofixup.t t/capture.t t/implicit_upstream.t t/repo.pl t/test.pl t/util.pl git-autofixup-0.004006/MANIFEST.SKIP000066400000000000000000000022341454612743400164610ustar00rootroot00000000000000#!start included /usr/share/perl5/core_perl/ExtUtils/MANIFEST.SKIP # Avoid version control files. \bRCS\b \bCVS\b \bSCCS\b ,v$ \B\.svn\b \B\.git\b \B\.gitignore\b \b_darcs\b \B\.cvsignore$ # Avoid VMS specific MakeMaker generated files \bDescrip.MMS$ \bDESCRIP.MMS$ \bdescrip.mms$ # Avoid Makemaker generated and utility files. \bMANIFEST\.bak \bMakefile$ \bblib/ \bMakeMaker-\d \bpm_to_blib\.ts$ \bpm_to_blib$ \bblibdirs\.ts$ # 6.18 through 6.25 generated this \b_eumm/ # 7.05_05 and above # Avoid Module::Build generated and utility files. \bBuild$ \b_build/ \bBuild.bat$ \bBuild.COM$ \bBUILD.COM$ \bbuild.com$ # and Module::Build::Tiny generated files \b_build_params$ # Avoid temp and backup files. ~$ \.old$ \#$ \b\.# \.bak$ \.tmp$ \.# \.rej$ \..*\.sw.?$ # Avoid OS-specific files/dirs # Mac OSX metadata \B\.DS_Store # Mac OSX SMB mount metadata files \B\._ # Avoid Devel::Cover and Devel::CoverX::Covered files. \bcover_db\b \bcovered\b # Avoid prove files \B\.prove$ # Avoid MYMETA files ^MYMETA\. #!end included /usr/share/perl5/core_perl/ExtUtils/MANIFEST.SKIP \.tgz$ \.tar\.gz$ MANIFEST\.SKIP ^xt \.sw[pmno]$ ^\.github ^release$ ^todo$ git-autofixup-0.004006/Makefile.PL000066400000000000000000000032611454612743400165360ustar00rootroot00000000000000package Makefile; use 5.008004; use strict; use warnings FATAL => 'all'; use ExtUtils::MakeMaker; # Return a hashref of dependencies in MakeMaker format. sub get_deps { return { 'Carp' => 0, 'File::Copy' => 0, 'File::Spec' => 0, 'File::Temp' => 0, 'Getopt::Long' => 0, 'IPC::Open3' => 0, 'Pod::Usage' => 0, 'strict' => 0, 'warnings' => 0, } } # Return a hashref of test dependencies in MakeMaker format. sub get_test_deps { return { 'Cwd' => 0, 'Data::Dumper' => 0, 'English' => 0, 'Test::More' => 0, 'Test::Pod' => '1.00', } } my %args = ( NAME => 'App::Git::Autofixup', VERSION_FROM => 'lib/App/Git/Autofixup.pm', ABSTRACT_FROM => 'lib/App/Git/Autofixup.pm', AUTHOR => 'Jordan Torbiak', LICENSE => 'artistic_2', MIN_PERL_VERSION => '5.008004', EXE_FILES => ['git-autofixup'], ); if (eval { ExtUtils::MakeMaker->VERSION(6.46) }) { $args{META_MERGE} = { 'meta-spec' => {version => 2}, resources => { repository => { type => 'git', url => 'https://github.com/torbiak/git-autofixup.git', web => 'https://github.com/torbiak/git-autofixup', }, bugtracker => { web => 'https://github.com/torbiak/git-autofixup/issues' }, }, } } my $deps = get_deps(); my $test_deps = get_test_deps(); if (eval { ExtUtils::MakeMaker->VERSION(6.46) }) { $args{TEST_REQUIRES} = $test_deps; $args{PREREQ_PM} = $deps; } else { $args{PREREQ_PM} = {%$deps, %$test_deps} } if (!caller()) { WriteMakefile(%args); } 1; git-autofixup-0.004006/README.pod000066400000000000000000000155401454612743400162300ustar00rootroot00000000000000=pod =head1 NAME App::Git::Autofixup - create fixup commits for topic branches =head1 SYNOPSIS git-autofixup [] [] =head1 DESCRIPTION F parses hunks of changes in the working directory out of C output and uses C to assign those hunks to commits in CrevisionE..HEAD>, which will typically represent a topic branch, and then creates fixup commits to be used with C. It is assumed that hunks near changes that were previously committed to the topic branch are related. CrevisionE> defaults to C, but this will only work if the current branch has an upstream/tracking branch. See C for info about how to specify revisions. If any changes have been staged to the index using C, then F will only consider staged hunks when trying to create fixup commits. A temporary index is used to create any resulting commits. By default a hunk will be included in a fixup commit if all the lines in the hunk's context blamed on topic branch commits refer to the same commit, so there's no ambiguity about which commit the hunk corresponds to. If there is ambiguity the assignment behaviour used under C<--strict 1> will be used to attempt to resolve it. If C<--strict 1> is given the same topic branch commit must be blamed for every removed line and at least one of the lines adjacent to each added line, and added lines must not be adjacent to lines blamed on other topic branch commits. All the same restrictions apply when C<--strict 2> is given, but each added line must be surrounded by lines blamed on the same topic branch commit. For example, the added line in the hunk below is adjacent to lines committed by commits C<99f370af> and C. If these are both topic branch commits then it's ambiguous which commit the added line is fixing up and the hunk will be ignored. COMMIT |LINE|HEAD |WORKING DIRECTORY 99f370af| 1|first line | first line | | |+added line a1eadbe2| 2|second line | second line But if that second line were instead blamed on an upstream commit (denoted by C<^>), the hunk would be added to a fixup commit for C<99f370af>: 99f370af| 1|first line | first line | | |+added line ^ | 2|second line | second line Output similar to this example can be generated by setting verbosity to 2 or greater by using the verbosity option multiple times, eg. C, and can be helpful in determining how a hunk will be handled. F is not to be used mindlessly. Always inspect the created fixup commits to ensure hunks have been assigned correctly, especially when used on a working directory that has been changed with a mix of fixups and new work. =head2 Articles =over =item L =item L =back =head1 OPTIONS =over =item -h Show usage. =item --help Show manpage. =item --version Show version. =item -v, --verbose Increase verbosity. Can be used up to two times. =item -c N, --context N Change the number of context lines C uses around hunks. Default: 3. This can change how hunks are assigned to fixup commits, especially with C<--strict 0>. =item -s N, --strict N Set how strict F is about assigning hunks to fixup commits. Default: 0. Strictness levels are described under DESCRIPTION. =item -g ARG, --gitopt ARG Specify option for git. Can be used multiple times. Useful for testing, to override config options that break git-autofixup, or to override global diff options to tweak what git-autofixup considers a hunk. Deprecated in favor of C environment variables; see C. Note ARG won't be wordsplit, so to give multiple arguments, such as for setting a config option like C<-c diff.algorithm>, this option must be used multiple times: C<-g -c -g diff.algorithm=patience>. =item -e, --exit-code Use more detailed exit codes: =over =item 0: All hunks have been assigned. =item 1: Only some hunks have been assigned. =item 2: No hunks have been assigned. =item 3: There was nothing to be assigned. =item 255: Unexpected error occurred. =back =back =head1 INSTALLATION If cpan is available, run C. Otherwise, copy F to a directory in C and ensure it has execute permissions. It can then be invoked as either C or C, since git searches C for appropriately named binaries. Git is distributed with Perl 5 for platforms not expected to already have it installed, but installing modules with cpan requires other tools that might not be available, such as make. This script has no dependencies outside of the standard library, so it is hoped that it works on any platform that Git does without much trouble. Requires a git supporting C: 1.7.4 or later. =head1 BUGS/LIMITATIONS git-autofixup works on Windows, but be careful not to use a perl compiled for cygwin with a git compiled for msys, such as L. It can be used from Git for Windows' "Git Bash" or "Git CMD", or you can install git using Cygwin's package manager and use git-autofixup from Cygwin. Note that while every release gets tested on Cygwin via the CPAN Testers network, testing with Git for Windows requires more effort since it's a constrained environment; thus it doesn't get tested as often. If you run into any issues, please report them on L. If a topic branch adds some lines in one commit and subsequently removes some of them in another, a hunk in the working directory that re-adds those lines will be assigned to fixup the first commit, and during rebasing they'll be removed again by the later commit. =head1 ACKNOWLEDGEMENTS F was inspired by a description of L in the L. While I was working on it I found L, by oktal3700, which was helpful to examine. =head1 COPYRIGHT AND LICENSE Copyright (C) 2017, Jordan Torbiak. This program is free software; you can redistribute it and/or modify it under the terms of the Artistic License v2.0. =cut git-autofixup-0.004006/git-autofixup000077500000000000000000000762231454612743400173270ustar00rootroot00000000000000#!/usr/bin/perl package Autofixup; use 5.008004; use strict; use warnings FATAL => 'all'; use Carp qw(croak); use File::Copy; use File::Spec (); use File::Temp; use Getopt::Long qw(:config bundling); use IPC::Open3; our $VERSION = 0.004006; my $VERBOSE; my @GIT_OPTIONS; # Strictness levels. my @STRICTNESS_LEVELS = ( my $CONTEXT = 0, my $ADJACENT = 1, my $SURROUNDED = 2, ); my $usage =<<'END'; usage: git-autofixup [] [] -h show usage --help show manpage --version show version -v, --verbose increase verbosity (use up to 2 times) -c N, --context N set number of diff context lines (default 3) -e, --exit-code use more detailed exit codes (see --help) -s N, --strict N set strictness (default 0) Assign a hunk to fixup a topic branch commit if: 0: either only one topic branch commit is blamed in the hunk context or blocks of added lines are adjacent to exactly one topic branch commit. Removing upstream lines is allowed for this level. 1: blocks of added lines are adjacent to exactly one topic branch commit 2: blocks of added lines are surrounded by exactly one topic branch commit Regardless of strictness level, removed lines are correlated with the commit they're blamed on, and all the blocks of changed lines in a hunk must be correlated with the same topic branch commit in order to be assigned to it. See the --help for more details. -g ARG, --gitopt ARG Specify option for git. Can be used multiple times. Deprecated in favor of GIT_CONFIG_{COUNT,KEY,VALUE} environment variables; see `git help config`. END # Parse hunks out of `git diff` output. Return an array of hunk hashrefs. sub parse_hunks { my $fh = shift; my ($file_a, $file_b); my @hunks; my $line; while ($line = <$fh>) { if ($line =~ /^--- (.*)/) { $file_a = dequote_diff_filename($1); } elsif ($line =~ /^\+\+\+ (.*)/) { $file_b = dequote_diff_filename($1); } elsif ($line =~ /^@@ -(\d+)(?:,(\d+))? \+\d+(?:,\d+)? @@/) { my $header = $line; next if $file_a ne $file_b; # Ignore creations and deletions. my $lines = []; while (1) { $line = <$fh>; if (!defined($line) || $line =~ /^[^ +\\-]/) { last; } push @{$lines}, $line; } push(@hunks, { file => $file_a, start => $1, count => defined($2) ? $2 : 1, header => $header, lines => $lines, }); # The next line after a hunk could be a header for the next commit # or hunk. redo if defined $line; } } return @hunks; } # Dequote and unescape filenames that appear in diff output. # # If the filename is otherwise "normal" but contains spaces it's followed by a # trailing tab, and if it contains uncommon control characters or non-ASCII # characters, then the filename gets surrounded in double-quotes and non-ASCII # characters get replaced with octal escape sequences. # # For details about exactly what gets quoted, see the sq_lookup array in # git/quote.c. # # Also, remove the side-marker prefix, by default `a/` or `b/`. sub dequote_diff_filename { $_ = shift; s/\t$//m; # Remove trailing tab. if (startswith($_, '"')) { s/^"|"$//gm; # Remove surrounding quotes. # Replace octal and control character escapes. s/\\((?:\d{3})|(?:[abtnvfr"\\]))/"qq(\\$1)"/eeg; } # Remove prefix. # a/b by default, or (i)ndex (w)orktree (c)ommit (o)bject if # diff.mnemonicPrefix is set. s#^[abiwco]/##; return $_; } sub git_cmd { return ('git', @GIT_OPTIONS, @_); } # With a linear git history there'll be a single merge base that's easy to # refer to with @{upstream}, but during an interactive rebase we need to get # the "current" branch from the rebase metadata. # # Unusual cases: # # While there can be multiple merge bases if there have been criss-cross # merges, there'll still be a single fork point unless the relevant reflog # entries have already been garbage-collected. # # When multiple upstreams are configured via `branch..merge` in git's # config the most correct approach is probably to find the fork-point for each # merge value and return those. But it seems unlikely that someone is doing # octopus merges and using git-autofixup, so we're not handling that specially # currently. sub find_merge_bases { my $upstream = '@{upstream}'; # If an interactive rebase is in progress, derive the upstream from the # rebase meatadata. my $gitdir = git_dir(); if (-e "$gitdir/rebase-merge") { my $branch = slurp("$gitdir/rebase-merge/head-name"); chomp $branch; $branch =~ s#^refs/heads/##; $upstream = "$branch\@{upstream}"; } # `git merge-base` will fail if there's no tracking branch. In that case # redirect stderr and communicate failure by returning an empty list. Also, # with the --fork-point option, no merge bases are returned if the relevant # reflog entries have been GC'd, so fall back to normal merge-bases. my @merge_bases = (); my ($out, $err, $exit_code) = capture(qw(git merge-base --all --fork-point), $upstream, 'HEAD'); if ($exit_code == 0) { @merge_bases = map {chomp; $_} split(/\n/, $out); } else { my ($out, $err, $exit_code) = capture(qw(git merge-base --all), $upstream, 'HEAD'); if ($exit_code != 0) { die "git merge-base: $err"; } @merge_bases = map {chomp; $_} split("\n", $out); } return wantarray ? @merge_bases : \@merge_bases; } sub git_dir { my ($out, $err, $exit_code) = capture(qw(git rev-parse --git-dir)); if ($exit_code != 0) { warn "git rev-parse --git-dir: $err\n"; die "Can't find repo's git dir\n"; } chomp $out; return $out; } sub toplevel_dir { my ($out, $err, $exit_code) = capture(qw(git rev-parse --show-toplevel)); if ($exit_code != 0) { warn "git rev-parse --show-toplevel: $err\n"; die "Can't find repo's toplevel dir\n"; } chomp $out; return $out; } # Run the given command, capture stdout and stderr, and return an array of # (stdout, stderr, exit_code). sub capture { open(my $out_fh, '>', undef) or die "create stdout tempfile: $!"; open(my $err_fh, '>', undef) or die "create stderr tempfile: $!"; my $pid = open3(my $in_fh, $out_fh, $err_fh, @_); waitpid $pid, 0; if ($? & 127) { my $signal = $? & 127; die "capture: child died with signal $signal; exiting"; } my $exit_code = $? >> 8; local $/; # slurp my $stdout = readline $out_fh; my $stderr = readline $err_fh; my @array = ($stdout, $stderr, $exit_code); return wantarray ? @array : \@array; } # Return a description of what $? means. sub child_error_desc { my $err = shift; if ($err == -1) { return "failed to execute: $!"; } elsif ($err & 127) { return "died with signal " . ($err & 127); } else { return "exited with " . ($err >> 8); } } sub slurp { my $filename = shift; open my $fh, '<', $filename or die "slurp $filename: $!"; local $/; my $content = readline $fh; return $content; } sub summary_for_commits { my @upstreams = @_; my %commits; my $negative = join(" ", map {"^$_"} @upstreams); my @lines = qx(git log --no-merges --format=%H:%s HEAD $negative); die "git log: " . child_error_desc($?) if $?; for (@lines) { chomp; my ($sha, $msg) = split ':', $_, 2; $commits{$sha} = $msg; } return \%commits; } # Return targets of fixup!/squash! commits. sub sha_aliases { my $summary_for = shift; my %aliases; my @targets = keys(%{$summary_for}); for my $sha (@targets) { my $summary = $summary_for->{$sha}; next if $summary !~ /^(?:fixup|squash)! (.*)/; my $prefix = $1; if ($prefix =~ /^(?:(?:fixup|squash)! ){2}/) { die "fixup commits for fixup commits aren't supported: $sha"; } my @matches = grep {startswith($summary_for->{$_}, $prefix)} @targets; if (@matches > 1) { die "ambiguous fixup commit target: multiple commit summaries start with: $prefix\n"; } elsif (@matches == 0) { die "no fixup target in topic branch: $sha\n"; } elsif (@matches == 1) { $aliases{$sha} = $matches[0]; } } return \%aliases; } # Given hunk, blame, and commit data, return the SHA for the commit the hunk # should fixup, or undef if an appropriate commit isn't found. sub fixup_sha { my $args = shift; my $hunk = $args->{hunk}; my $blame = $args->{blame}; my $summary_for = $args->{summary_for}; my $strict = $args->{strict}; if (grep {!defined} ($hunk, $blame, $summary_for, $strict)) { croak 'missing argument'; } my @targets; if ($args->{strict} == $CONTEXT) { @targets = fixup_targets_from_all_context($args); my @topic_targets = grep {defined $summary_for->{$_}} @targets; if (@topic_targets > 1) { # The context assignment is ambiguous, but an adjacency assignment # might not be. @targets = fixup_targets_from_adjacent_context($args); } } else { @targets = fixup_targets_from_adjacent_context($args); } my $upstream_is_blamed = grep {!defined $summary_for->{$_}} @targets; my @topic_targets = grep {defined $summary_for->{$_}} @targets; if ($strict && $upstream_is_blamed) { $VERBOSE && print hunk_desc($hunk), " changes lines blamed on upstream\n"; return; } elsif (@topic_targets > 1) { $VERBOSE && print hunk_desc($hunk), " has multiple targets\n"; return; } elsif (@topic_targets == 0) { $VERBOSE && print hunk_desc($hunk), " has no targets\n"; return; } return $topic_targets[0]; } sub hunk_desc { my $hunk = shift; return join " ", ( $hunk->{file}, $hunk->{header} =~ /(@@[^@]*@@)/, ); } sub fixup_targets_from_all_context { my $args = shift; my ($hunk, $blame, $summary_for) = @{$args}{qw(hunk blame summary_for)}; croak 'missing argument' if grep {!defined} ($hunk, $blame, $summary_for); my @targets = uniq(map {$_->{sha}} values(%{$blame})); return wantarray ? @targets : \@targets; } sub uniq { my %seen; return grep {!$seen{$_}++} @_; } sub fixup_targets_from_adjacent_context { my $args = shift; my $hunk = $args->{hunk}; my $blame = $args->{blame}; my $summary_for = $args->{summary_for}; my $strict = $args->{strict}; if (grep {!defined} ($hunk, $blame, $summary_for, $strict)) { croak 'missing argument'; } my $blame_indexes = blame_indexes($hunk); my %blamed; my $diff = $hunk->{lines}; for (my $di = 0; $di < @{$diff}; $di++) { # diff index my $bi = $blame_indexes->[$di]; my $line = $diff->[$di]; if (startswith($line, '-')) { my $sha = $blame->{$bi}{sha}; $blamed{$sha} = 1; } elsif (startswith($line, '+')) { my @lines; if ($di > 0 && defined $blame->{$bi-1}) { push @lines, $bi-1; } if (defined $blame->{$bi}) { push @lines, $bi; } my @adjacent_shas = uniq(map {$_->{sha}} @{$blame}{@lines}); my @target_shas = grep {defined $summary_for->{$_}} @adjacent_shas; # Note that lines at the beginning or end of a file can be # "surrounded" by a single line. my $is_surrounded = @target_shas > 0 && @target_shas == @adjacent_shas && $target_shas[0] eq $target_shas[-1]; my $is_adjacent = @target_shas == 1; if ($is_surrounded || ($strict < $SURROUNDED && $is_adjacent)) { $blamed{$target_shas[0]} = 1; } while ($di < @$diff-1 && startswith($diff->[$di+1], '+')) { $di++; } } } my @targets = keys %blamed; return wantarray ? @targets : \@targets; } sub startswith { my ($haystack, $needle) = @_; return index($haystack, $needle, 0) == 0; } # Map lines in a hunk's diff to the corresponding `git blame HEAD` output. sub blame_indexes { my $hunk = shift; my @indexes; my $bi = $hunk->{start}; for (my $di = 0; $di < @{$hunk->{lines}}; $di++) { push @indexes, $bi; my $first = substr($hunk->{lines}[$di], 0, 1); if ($first eq '-' or $first eq ' ') { $bi++; } # Don't increment $bi for added lines. } return \@indexes; } sub print_hunk_blamediff { my $args = shift; my $fh = $args->{fh}; my $hunk = $args->{hunk}; my $summary_for = $args->{summary_for}; my $blame = $args->{blame}; my $blame_indexes = $args->{blame_indexes}; if (grep {!defined} ($fh, $hunk, $summary_for, $blame, $blame_indexes)) { croak 'missing argument'; } my $format = "%-8.8s|%4.4s|%-30.30s|%-30.30s\n"; for (my $i = 0; $i < @{$hunk->{lines}}; $i++) { my $line_r = $hunk->{lines}[$i]; my $bi = $blame_indexes->[$i]; my $sha = defined $blame->{$bi} ? $blame->{$bi}{sha} : undef; my $display_sha = defined($sha) ? $sha : q{}; my $display_bi = $bi; if (startswith($line_r, '+')) { $display_sha = q{}; # For added lines. $display_bi = q{}; } if (defined($sha) && !defined($summary_for->{$sha})) { # For lines from before the given upstream revision. $display_sha = '^'; } my $line_l = ''; if (defined $blame->{$bi} && !startswith($line_r, '+')) { $line_l = $blame->{$bi}{text}; } for ($line_l, $line_r) { # For the table to line up, tabs need to be converted to a string of fixed width. s/\t/^I/g; # Remove trailing newlines and carriage returns. If more trailing # whitespace is removed, that's fine. $_ = rtrim($_); } printf {$fh} $format, $display_sha, $display_bi, $line_l, $line_r; } print {$fh} "\n"; return; } sub rtrim { my $s = shift; $s =~ s/\s+\z//; return $s; } sub blame { my ($hunk, $alias_for, $grafts_file) = @_; if ($hunk->{count} == 0) { return {}; } my @cmd = git_cmd( 'blame', '--porcelain', '-L' => "$hunk->{start},+$hunk->{count}", '-S' => $grafts_file, 'HEAD', '--', "$hunk->{file}"); my %blame; my ($sha, $line_num); open(my $fh, '-|', @cmd) or die "run git blame: $!\n"; while (my $line = <$fh>) { if ($line =~ /^([0-9a-f]{40}) \d+ (\d+)/) { ($sha, $line_num) = ($1, $2); } if (startswith($line, "\t")) { if (defined $alias_for->{$sha}) { $sha = $alias_for->{$sha}; } $blame{$line_num} = {sha => $sha, text => substr($line, 1)}; } } close($fh) or die "git blame: " . child_error_desc($?) . "\n"; return \%blame; } sub diff_hunks { my $num_context_lines = shift; my @cmd = git_cmd(qw(-c diff.noprefix=false diff --no-ext-diff --ignore-submodules), "-U$num_context_lines"); if (is_index_dirty()) { push @cmd, "--cached"; } open(my $fh, '-|', @cmd) or die "run git diff: $!"; my @hunks = parse_hunks($fh, keep_lines => 1); close($fh) or die "git diff: " . child_error_desc($?) . "\n"; return wantarray ? @hunks : \@hunks; } sub commit_fixup { my ($sha, $hunks) = @_; open my $fh, '|-', git_cmd(qw(apply --unidiff-zero --cached -)) or die "git apply: $!\n"; for my $hunk (@{$hunks}) { print({$fh} "--- a/$hunk->{file}\n", "+++ b/$hunk->{file}\n", $hunk->{header}, @{$hunk->{lines}}, ); } close $fh or die "git apply: " . child_error_desc($?) . "\n"; my (undef, $err, $exit_code) = capture(git_cmd('commit', "--fixup=$sha")); if ($exit_code != 0) { warn "git commit: $err\n"; die "git commit exited with $exit_code\n"; } return; } sub is_index_dirty { return system(git_cmd(qw(diff-index --cached HEAD --quiet))) != 0; } sub fixup_hunks_by_sha { my $args = shift; my $hunks = $args->{hunks}; my $blame_for = $args->{blame_for}; my $summary_for = $args->{summary_for}; my $strict = $args->{strict}; if (grep {!defined} ($hunks, $blame_for, $summary_for, $strict)) { croak 'missing argument'; } my %hunks_for; for my $hunk (@{$hunks}) { my $blame = $blame_for->{$hunk}; my $sha = fixup_sha({ hunk => $hunk, blame => $blame, summary_for => $summary_for, strict => $strict, }); if ($sha && $VERBOSE) { printf "%s fixes %s %s\n", hunk_desc($hunk), substr($sha, 0, 8), $summary_for->{$sha}; } if ($VERBOSE > 1) { print_hunk_blamediff({ fh => *STDOUT, hunk => $hunk, summary_for => $summary_for, blame => $blame, blame_indexes => blame_indexes($hunk) }); } next if !$sha; push @{$hunks_for{$sha}}, $hunk; } return \%hunks_for; } # Return SHAs in some consistent order. # # Currently they're ordered by how early their assigned hunks appear in the # diff output. This assumes $hunks is in the order it was parsed from the diff. # This ordering seems nice since it'd be similar to the order a human would # make commits in if they were working their way down the diff. sub ordered_shas { my $hunks = shift; my $sha_for = shift; my @ordered = (); for my $hunk (@{$hunks}) { if (defined $sha_for->{$hunk}) { push @ordered, $sha_for->{$hunk}; } } return uniq(@ordered); } # Reverse the sha->hunks hashef and return a hunk->sha hashref. sub sha_for_hunk_map { my $hunks_for = shift; my %sha_for; for my $sha (keys %{$hunks_for}) { for my $hunk (@{$hunks_for->{$sha}}) { if (defined $sha_for{$hunk}) { die "multiple SHAs for hunk"; # This should never happen. } $sha_for{$hunk} = $sha; } } return \%sha_for; } sub exit_code { my ($hunks, $hunks_for) = @_; my $hunk_count = scalar @{$hunks}; my $assigned_hunk_count = 0; for (values %{$hunks_for}) { $assigned_hunk_count += @{$_}; } my $rc; if ($hunk_count == 0) { $rc = 3; # no hunks to assign } elsif ($assigned_hunk_count == 0) { $rc = 2; # hunks exist, but none assigned } elsif ($assigned_hunk_count < $hunk_count) { $rc = 1; # not all hunks assigned } elsif ($hunk_count == $assigned_hunk_count) { $rc = 0; # all hunks assigned } else { die "unexpected conditions when choosing exit code"; } return $rc; } # Create a temporary index so we can craft commits with already-staged hunks. # Return a File::Temp object so the caller has control over its lifetime. sub create_temp_index { my $old_index = shift; my $tempfile = File::Temp->new( TEMPLATE => 'git-autofixup_index.XXXXXX', DIR => File::Spec->tmpdir()); # The index ought to be equivalent to HEAD. The fastest way to create it # is to start with the current index, and subtract the changes since HEAD. if (not defined($old_index)) { my $gitdir = git_dir(); $old_index = "$gitdir/index"; } close $tempfile or die "close temp index: $!"; copy($old_index, $tempfile->filename()) or die "Can't copy Git index '$old_index' to '$tempfile': $!\n"; $ENV{GIT_INDEX_FILE} = $tempfile->filename(); # Remove any staged changes from the new index - we want to turn them into fixup commits. my $index_changes = qx(git diff-index --patch --no-ext-diff --ignore-submodules --cached HEAD); die "git diff-index: " . child_error_desc($?) if $?; open my $fh, '|-', git_cmd(qw(apply --cached --whitespace=nowarn --reverse -)) or die "run git apply: $!\n"; print $fh $index_changes; close $fh or die "git apply: " . child_error_desc($?) . "\n"; return $tempfile; } # Create a grafts file for `git blame -S` that basically says the upstream # commit doesn't have any parents, resulting in blame only searching back as # far back as the upstream commit. sub create_grafts_file { my @upstreams = @_; my $grafts_file = File::Temp->new( TEMPLATE => 'git-autofixup_grafts.XXXXXX', DIR => File::Spec->tmpdir()); open(my $fh, '>', $grafts_file) or die "Can't open $grafts_file: $!\n"; for (@upstreams) { print $fh $_, "\n"; } close($fh) or die "close grafts file: $!\n"; return $grafts_file; } sub rev_parse { my $rev = shift; my ($out, $err, $exit_code) = capture(qw(git rev-parse --verify --end-of-options), $rev); if ($exit_code != 0) { warn "git rev-parse: $err\n"; die "Can't resolve given revision\n"; } chomp $out; return $out; } sub main { $VERBOSE = 0; my $help; my $man; my $show_version; my $strict = $CONTEXT; my $num_context_lines = 3; my $dryrun; my $use_detailed_exit_codes; GetOptions( 'h' => \$help, 'help' => \$man, 'version' => \$show_version, 'verbose|v+' => \$VERBOSE, 'strict|s=i' => \$strict, 'context|c=i' => \$num_context_lines, 'dryrun|n' => \$dryrun, 'gitopt|g=s' => \@GIT_OPTIONS, 'exit-code' => \$use_detailed_exit_codes, ) or return 1; if ($help) { print $usage; return 0; } if ($show_version) { print "$VERSION\n"; return 0; } if ($man) { eval { require Pod::Usage; }; if ($@) { die <<'EOF'; Pod::Usage unavailable for formatting the manual. The manual can be found at the end of the git-autofixup script. EOF } Pod::Usage::pod2usage(-exitval => 0, -verbose => 2); } if (@GIT_OPTIONS) { warn <<'EOF'; --gitopt|-g is deprecated and will be removed in a future release. Please use the GIT_CONFIG_{COUNT,KEY,VALUE} environment variables instead; see `git help config`. EOF } # "upstream" revisions as 40 byte SHA1 hex hashes. my @upstreams = (); if (@ARGV == 1) { my $raw_upstream = shift @ARGV; my $upstream = rev_parse("${raw_upstream}^{commit}"); push @upstreams, $upstream; } else { @upstreams = find_merge_bases(); if (!@upstreams) { die "Can't find tracking branch. Please specify a revision.\n"; } } if ($num_context_lines < 0) { die "number of context lines must be zero or greater\n"; } if (!grep { $strict == $_ } @STRICTNESS_LEVELS) { die "invalid strictness level: $strict\n"; } elsif ($strict > 0 && $num_context_lines == 0) { die "strict hunk assignment requires context\n"; } my $toplevel = toplevel_dir(); chdir $toplevel or die "cd to toplevel: $!\n"; my $hunks = diff_hunks($num_context_lines); my $summary_for = summary_for_commits(@upstreams); my $alias_for = sha_aliases($summary_for); my $grafts_file = create_grafts_file(@upstreams); my %blame_for = map {$_ => blame($_, $alias_for, $grafts_file)} @{$hunks}; my $hunks_for = fixup_hunks_by_sha({ hunks => $hunks, blame_for => \%blame_for, summary_for => $summary_for, strict => $strict, }); my @ordered_shas = ordered_shas($hunks, sha_for_hunk_map($hunks_for)); if ($dryrun) { if ($use_detailed_exit_codes) { return exit_code($hunks, $hunks_for); } return 0; } my $old_index = $ENV{GIT_INDEX_FILE}; local $ENV{GIT_INDEX_FILE}; # Throw away changes between main() calls. if (is_index_dirty()) { # Limit the tempfile's lifetime to the execution of main(). my $tempfile = create_temp_index($old_index); } for my $sha (@ordered_shas) { my $fixup_hunks = $hunks_for->{$sha}; commit_fixup($sha, $fixup_hunks); } if ($use_detailed_exit_codes) { return exit_code($hunks, $hunks_for); } return 0; } if (!caller()) { exit main(); } 1; __END__ =pod =head1 NAME App::Git::Autofixup - create fixup commits for topic branches =head1 SYNOPSIS git-autofixup [] [] =head1 DESCRIPTION F parses hunks of changes in the working directory out of C output and uses C to assign those hunks to commits in CrevisionE..HEAD>, which will typically represent a topic branch, and then creates fixup commits to be used with C. It is assumed that hunks near changes that were previously committed to the topic branch are related. CrevisionE> defaults to C, but this will only work if the current branch has an upstream/tracking branch. See C for info about how to specify revisions. If any changes have been staged to the index using C, then F will only consider staged hunks when trying to create fixup commits. A temporary index is used to create any resulting commits. By default a hunk will be included in a fixup commit if all the lines in the hunk's context blamed on topic branch commits refer to the same commit, so there's no ambiguity about which commit the hunk corresponds to. If there is ambiguity the assignment behaviour used under C<--strict 1> will be used to attempt to resolve it. If C<--strict 1> is given the same topic branch commit must be blamed for every removed line and at least one of the lines adjacent to each added line, and added lines must not be adjacent to lines blamed on other topic branch commits. All the same restrictions apply when C<--strict 2> is given, but each added line must be surrounded by lines blamed on the same topic branch commit. For example, the added line in the hunk below is adjacent to lines committed by commits C<99f370af> and C. If these are both topic branch commits then it's ambiguous which commit the added line is fixing up and the hunk will be ignored. COMMIT |LINE|HEAD |WORKING DIRECTORY 99f370af| 1|first line | first line | | |+added line a1eadbe2| 2|second line | second line But if that second line were instead blamed on an upstream commit (denoted by C<^>), the hunk would be added to a fixup commit for C<99f370af>: 99f370af| 1|first line | first line | | |+added line ^ | 2|second line | second line Output similar to this example can be generated by setting verbosity to 2 or greater by using the verbosity option multiple times, eg. C, and can be helpful in determining how a hunk will be handled. F is not to be used mindlessly. Always inspect the created fixup commits to ensure hunks have been assigned correctly, especially when used on a working directory that has been changed with a mix of fixups and new work. =head2 Articles =over =item L =item L =back =head1 OPTIONS =over =item -h Show usage. =item --help Show manpage. =item --version Show version. =item -v, --verbose Increase verbosity. Can be used up to two times. =item -c N, --context N Change the number of context lines C uses around hunks. Default: 3. This can change how hunks are assigned to fixup commits, especially with C<--strict 0>. =item -s N, --strict N Set how strict F is about assigning hunks to fixup commits. Default: 0. Strictness levels are described under DESCRIPTION. =item -g ARG, --gitopt ARG Specify option for git. Can be used multiple times. Useful for testing, to override config options that break git-autofixup, or to override global diff options to tweak what git-autofixup considers a hunk. Deprecated in favor of C environment variables; see C. Note ARG won't be wordsplit, so to give multiple arguments, such as for setting a config option like C<-c diff.algorithm>, this option must be used multiple times: C<-g -c -g diff.algorithm=patience>. =item -e, --exit-code Use more detailed exit codes: =over =item 0: All hunks have been assigned. =item 1: Only some hunks have been assigned. =item 2: No hunks have been assigned. =item 3: There was nothing to be assigned. =item 255: Unexpected error occurred. =back =back =head1 INSTALLATION If cpan is available, run C. Otherwise, copy F to a directory in C and ensure it has execute permissions. It can then be invoked as either C or C, since git searches C for appropriately named binaries. Git is distributed with Perl 5 for platforms not expected to already have it installed, but installing modules with cpan requires other tools that might not be available, such as make. This script has no dependencies outside of the standard library, so it is hoped that it works on any platform that Git does without much trouble. Requires a git supporting C: 1.7.4 or later. =head1 BUGS/LIMITATIONS git-autofixup works on Windows, but be careful not to use a perl compiled for cygwin with a git compiled for msys, such as L. It can be used from Git for Windows' "Git Bash" or "Git CMD", or you can install git using Cygwin's package manager and use git-autofixup from Cygwin. Note that while every release gets tested on Cygwin via the CPAN Testers network, testing with Git for Windows requires more effort since it's a constrained environment; thus it doesn't get tested as often. If you run into any issues, please report them on L. If a topic branch adds some lines in one commit and subsequently removes some of them in another, a hunk in the working directory that re-adds those lines will be assigned to fixup the first commit, and during rebasing they'll be removed again by the later commit. =head1 ACKNOWLEDGEMENTS F was inspired by a description of L in the L. While I was working on it I found L, by oktal3700, which was helpful to examine. =head1 COPYRIGHT AND LICENSE Copyright (C) 2017, Jordan Torbiak. This program is free software; you can redistribute it and/or modify it under the terms of the Artistic License v2.0. =cut git-autofixup-0.004006/lib/000077500000000000000000000000001454612743400153305ustar00rootroot00000000000000git-autofixup-0.004006/lib/App/000077500000000000000000000000001454612743400160505ustar00rootroot00000000000000git-autofixup-0.004006/lib/App/Git/000077500000000000000000000000001454612743400165735ustar00rootroot00000000000000git-autofixup-0.004006/lib/App/Git/Autofixup.pm000066400000000000000000000005241454612743400211160ustar00rootroot00000000000000package App::Git::Autofixup; use strict; use warnings FATAL => 'all'; our $VERSION = 0.004006; =head1 NAME App::Git::Autofixup - create fixup commits for topic branches =head1 DESCRIPTION This is a stub module, see F for details of the app. =head1 AUTHOR Jordan Torbiak =head1 LICENSE Artistic License 2.0 =cut 1; git-autofixup-0.004006/release000077500000000000000000000021631454612743400161320ustar00rootroot00000000000000#!/bin/bash # Release tasks. # # This evolved out of following the guide at # https://www.perl.com/article/how-to-upload-a-script-to-cpan/. set -euo pipefail mk_readme() { perldoc -u git-autofixup >README.pod } mk_tarball() { perl Makefile.PL && make dist || return 1 } upload() { local tarball=${1:?No tarball given}; shift cpan-upload -u TORBIAK "$tarball" } get_version() { grep -F 'our $VERSION = ' lib/App/Git/Autofixup.pm git-autofixup } set_version() { local version=${1:?No version given}; shift sed -E -i '/our \$VERSION = [0-9]\.[0-9]+;/c our $VERSION = '"$version"';' \ lib/App/Git/Autofixup.pm git-autofixup } release() { local version=${1:?No version given}; shift set_version "$version" && mk_readme && prove -l t xt && make manifest || return 1 if ! grep -E "^# $version" Changes; then echo "Section for $version not found in Changes" >&2 return 1 fi git commit -am "Version $version" && git tag "v$version" && mk_tarball && upload "App-Git-Autofixup-${version%%000}.tar.gz" } cd "${BASH_SOURCE%/*}" "$@" git-autofixup-0.004006/t/000077500000000000000000000000001454612743400150255ustar00rootroot00000000000000git-autofixup-0.004006/t/autofixup.t000077500000000000000000000273021454612743400172450ustar00rootroot00000000000000#!/usr/bin/perl use strict; use warnings FATAL => 'all'; use English qw(-no_match_vars); use Test::More; require './t/test.pl'; require './t/util.pl'; Util::check_test_deps(); plan tests => 43; Test::autofixup_strict( name => "single-line change gets autofixed", strict => [0..2], topic_commits => [{a => "a1\n"}], unstaged => {a => "a2\n"}, exit_code => 0, log_want => <<'EOF' fixup! commit0 diff --git a/a b/a index da0f8ed..c1827f0 100644 --- a/a +++ b/a @@ -1 +1 @@ -a1 +a2 EOF ); Test::autofixup_strict( name => "adjacent change gets autofixed", strict => [0..1], upstream_commits => [{a => "a3\n"}], topic_commits => [{a => "a1\na3\n"}], unstaged => {a => "a1\na2\na3\n"}, exit_code => 0, log_want => <<'EOF' fixup! commit1 diff --git a/a b/a index 76642d4..2cdcdb0 100644 --- a/a +++ b/a @@ -1,2 +1,3 @@ a1 +a2 a3 EOF ); Test::autofixup( name => "adjacent change doesn't get autofixed if strict=2", upstream_commits => [{a => "a3\n"}], topic_commits => [{a => "a1\na3\n"}], unstaged => {a => "a1\na2\na3\n"}, log_want => '', autofixup_opts => ['-s2'], exit_code => 2, ); Test::autofixup( name => 'fixups are created for additions surrounded by topic commit lines when strict=2', topic_commits => [{a => "a1\na3\n", b => "b1\n", c => "c2\n"}], unstaged => {a => "a1\na2\na3\n", b => "b1\nb2\n", c => "c1\nc2\n"}, autofixup_opts => ['-s2'], exit_code => 0, log_want => <<'EOF' fixup! commit0 diff --git a/a b/a index 76642d4..2cdcdb0 100644 --- a/a +++ b/a @@ -1,2 +1,3 @@ a1 +a2 a3 diff --git a/b b/b index c9c6af7..9b89cd5 100644 --- a/b +++ b/b @@ -1 +1,2 @@ b1 +b2 diff --git a/c b/c index 16f9ec0..d0aaf97 100644 --- a/c +++ b/c @@ -1 +1,2 @@ +c1 c2 EOF ); Test::autofixup_strict( name => "removed file doesn't get autofixed", strict => [0..2], topic_commits => [sub { Util::write_file(a => "a1\n"); }], unstaged => sub { unlink 'a'; }, exit_code => 3, log_want => '', ); Test::autofixup_strict( name => "re-added file doesn't get autofixed", strict => [0..2], topic_commits => [ sub { Util::write_file(a => "a1\n"); }, sub { unlink 'a'; }, ], unstaged => sub { Util::write_file(a => "a1a\n"); }, exit_code => 3, log_want => '', ); Test::autofixup_strict( name => "re-added line gets autofixed into the commit blamed for the adjacent context", # During rebase the line will just get removed again by the next commit. # --strict can be used to avoid creating a fixup in this case, where the # added line is adjacent to only one of a topic commit's blamed lines, # but not if it's surrounded by them. It seems possible to avoid # potentially confusing situations like this by parsing the diffs of the # topic commits and tracking changes in files' line numbers, but it's # doubtful that it would be worth it. strict => [0..2], topic_commits => [ {a => "a1\na2\n"}, {a => "a1\n"}, ], exit_code => 0, unstaged => {a => "a1\na2\n"}, log_want => <<'EOF' fixup! commit0 diff --git a/a b/a index da0f8ed..0016606 100644 --- a/a +++ b/a @@ -1 +1,2 @@ a1 +a2 EOF ); Test::autofixup_strict( name => "removed lines get autofixed", strict => [0..2], topic_commits => [{a => "a1\n", b => "b1\nb2\n"}], unstaged => {a => "", b => "b2\n"}, exit_code => 0, log_want => <<'EOF' fixup! commit0 diff --git a/a b/a index da0f8ed..e69de29 100644 --- a/a +++ b/a @@ -1 +0,0 @@ -a1 diff --git a/b b/b index 9b89cd5..e6bfff5 100644 --- a/b +++ b/b @@ -1,2 +1 @@ -b1 b2 EOF ); Test::autofixup_strict( name => 'no fixups are created for upstream commits', strict => [0..2], upstream_commits => [{a => "a1\n"}], unstaged => {a => "a1a\n"}, exit_code => 2, log_want => '', ); Test::autofixup( name => 'fixups are created for hunks changing lines blamed by upstream if strict=0', # This depends on the number of context lines kept when creating diffs. git # keeps 3 by default. upstream_commits => [{a => "a1\na2\na3\n"}], topic_commits => [{a => "a1\na2a\na3a\n"}], unstaged => {a => "a1b\na2b\na3b\n"}, exit_code => 0, log_want => <<'EOF' fixup! commit1 diff --git a/a b/a index 125d560..cc1aa32 100644 --- a/a +++ b/a @@ -1,3 +1,3 @@ -a1 -a2a -a3a +a1b +a2b +a3b EOF ); Test::autofixup_strict( name => 'no fixups are created for hunks changing lines blamed by upstream if strict > 0', # This depends on the number of context lines kept when creating diffs. git # keeps 3 by default. strict => [1..2], upstream_commits => [{a => "a1\na2\na3\n"}], topic_commits => [{a => "a1\na2a\na3a\n"}], unstaged => {a => "a1b\na2b\na3b\n"}, exit_code => 2, log_want => '', ); Test::autofixup_strict( name => "hunks blamed on a fixup! commit are assigned to that fixup's target", strict => [0..2], topic_commits => [ {a => "a1\n"}, sub { Util::write_file(a => "a2\n"); Util::run(qw(git commit -a --fixup=HEAD)); }, ], exit_code => 0, unstaged => {a => "a2\na3\n"}, log_want => <<'EOF' fixup! commit0 diff --git a/a b/a index c1827f0..b792f74 100644 --- a/a +++ b/a @@ -1 +1,2 @@ a2 +a3 EOF ); Test::autofixup( name => "removed line gets autofixed when context=0", topic_commits => [{a => "a1\na2\n"}], unstaged => {a => "a1\n"}, autofixup_opts => ['-c' => 0], exit_code => 0, log_want => <<'EOF' fixup! commit0 diff --git a/a b/a index 0016606..da0f8ed 100644 --- a/a +++ b/a @@ -1,2 +1 @@ a1 -a2 EOF ); Test::autofixup( name => "added line is ignored when context=0", topic_commits => [{a => "a1\n"}], unstaged => {a => "a1\na2\n"}, autofixup_opts => ['-c' => 0], exit_code => 2, log_want => '', ); Test::autofixup( name => "ADJACENCY assignment is used as a fallback for multiple context targets", topic_commits => [ {a => "a1\n"}, {a => "a1\na2\n"}, ], exit_code => 0, unstaged => {a => "a1\na2a\n"}, log_want => <<'EOF' fixup! commit1 diff --git a/a b/a index 0016606..a0ef52c 100644 --- a/a +++ b/a @@ -1,2 +1,2 @@ a1 -a2 +a2a EOF ); Test::autofixup( name => "Works when run in a subdir of the repo root", topic_commits => [ sub { mkdir 'sub' or die $!; chdir 'sub' or die $!; Util::write_file("a", "a1\n"); } ], exit_code => 0, unstaged => {'a' => "a1\na2\n"}, log_want => <<'EOF' fixup! commit0 diff --git a/sub/a b/sub/a index da0f8ed..0016606 100644 --- a/sub/a +++ b/sub/a @@ -1 +1,2 @@ a1 +a2 EOF ); Test::autofixup( name => "file without newline at EOF gets autofixed", topic_commits => [{a => "a1\na2"}], unstaged => {'a' => "a1\na2\n"}, exit_code => 0, log_want => <<'EOF' fixup! commit0 diff --git a/a b/a index c928c51..0016606 100644 --- a/a +++ b/a @@ -1,2 +1,2 @@ a1 -a2 \ No newline at end of file +a2 EOF ); Test::autofixup( name => "multiple hunks in the same file get autofixed", topic_commits => [ {a => "a1.0\na2\na3\na4\na5\na6\na7\na8\na9.0\n"}, {a => "a1.0\na2\na3\na4\na5\na6\na7\na8\na9.1\n"}, {a => "a1.2\na2\na3\na4\na5\na6\na7\na8\na9.1\n"}, ], unstaged => {'a' => "a1.3\na2\na3\na4\na5\na6\na7\na8\na9.3\n"}, exit_code => 0, log_want => <<'EOF' fixup! commit1 diff --git a/a b/a index d9f44da..5b9ebcd 100644 --- a/a +++ b/a @@ -6,4 +6,4 @@ a5 a6 a7 a8 -a9.1 +a9.3 fixup! commit2 diff --git a/a b/a index 50de7e8..d9f44da 100644 --- a/a +++ b/a @@ -1,4 +1,4 @@ -a1.2 +a1.3 a2 a3 a4 EOF ); Test::autofixup( name => "single-line change gets autofixed when mnemonic prefixes are enabled", topic_commits => [{a => "a1\n"}], unstaged => {a => "a2\n"}, git_config => {'diff.mnemonicPrefix' => 'true'}, exit_code => 0, log_want => <<'EOF' fixup! commit0 diff --git a/a b/a index da0f8ed..c1827f0 100644 --- a/a +++ b/a @@ -1 +1 @@ -a1 +a2 EOF ); Test::autofixup( name => "single-line change gets autofixed when diff.external is set", topic_commits => [{a => "a1\n"}], unstaged => {a => "a2\n"}, git_config => {'diff.external' => 'vimdiff'}, exit_code => 0, log_want => <<'EOF' fixup! commit0 diff --git a/a b/a index da0f8ed..c1827f0 100644 --- a/a +++ b/a @@ -1 +1 @@ -a1 +a2 EOF ); Test::autofixup( name => 'exit code is 1 when some hunks are assigned', upstream_commits => [{a => "a1\n"}], topic_commits => [{b => "b1\n"}], unstaged => {a => "a1a\n", b => "b2\n"}, exit_code => 1, log_want => <<'EOF' fixup! commit1 diff --git a/b b/b index c9c6af7..e6bfff5 100644 --- a/b +++ b/b @@ -1 +1 @@ -b1 +b2 EOF ); Test::autofixup( name => "multiple hunks to the same commit get autofixed", topic_commits => [ {a => "a1.0\na2\na3\na4\na5\na6\na7\na8\na9.0\n"}, {b => "b1.0\n"}, ], unstaged => {'a' => "a1.1\na2\na3\na4\na5\na6\na7\na8\na9.1\n", b => "b1.1\n"}, exit_code => 0, log_want => q{fixup! commit1 diff --git a/b b/b index 253a619..6419a9e 100644 --- a/b +++ b/b @@ -1 +1 @@ -b1.0 +b1.1 fixup! commit0 diff --git a/a b/a index 5d11004..0054137 100644 --- a/a +++ b/a @@ -1,4 +1,4 @@ -a1.0 +a1.1 a2 a3 a4 @@ -6,4 +6,4 @@ a5 a6 a7 a8 -a9.0 +a9.1 }); Test::autofixup( name => "only staged hunks get autofixed", topic_commits => [{a => "a1\n", b => "b1\n"}], staged => {a => "a2\n"}, unstaged => {b => "b2\n"}, exit_code => 0, log_want => <<'EOF' fixup! commit0 diff --git a/a b/a index da0f8ed..c1827f0 100644 --- a/a +++ b/a @@ -1 +1 @@ -a1 +a2 EOF , unstaged_want => <<'EOF' diff --git a/b b/b index c9c6af7..e6bfff5 100644 --- a/b +++ b/b @@ -1 +1 @@ -b1 +b2 EOF ); Test::autofixup( name => "staged hunks that aren't autofixed remain in index", upstream_commits => [{b => "b1\n"}], topic_commits => [{a => "a1\n", , c => "c1\n"}], staged => {a => "a2\n", b => "b2\n"}, unstaged => {c => "c2\n"}, exit_code => 1, log_want => <<'EOF' fixup! commit1 diff --git a/a b/a index da0f8ed..c1827f0 100644 --- a/a +++ b/a @@ -1 +1 @@ -a1 +a2 EOF , unstaged_want => <<'EOF' diff --git a/b b/b index c9c6af7..e6bfff5 100644 --- a/b +++ b/b @@ -1 +1 @@ -b1 +b2 diff --git a/c b/c index ae93045..16f9ec0 100644 --- a/c +++ b/c @@ -1 +1 @@ -c1 +c2 EOF , staged_want => <<'EOF' diff --git a/b b/b index c9c6af7..e6bfff5 100644 --- a/b +++ b/b @@ -1 +1 @@ -b1 +b2 EOF ); Test::autofixup( name => "filename with spaces", topic_commits => [{"filename with spaces" => "a1\n"}], unstaged => {"filename with spaces" => "a2\n"}, exit_code => 0, log_want => <<'EOF' fixup! commit0 diff --git a/filename with spaces b/filename with spaces index da0f8ed..c1827f0 100644 --- a/filename with spaces +++ b/filename with spaces @@ -1 +1 @@ -a1 +a2 EOF ); Test::autofixup( name => "filename with unusual characters", topic_commits => [{"ff\f nak\025 dq\" fei飞.txt" => "a1\n"}], unstaged => {"ff\f nak\025 dq\" fei飞.txt" => "a2\n"}, exit_code => 0, log_want => <<'EOF' fixup! commit0 diff --git "a/ff\f nak\025 dq\" fei\351\243\236.txt" "b/ff\f nak\025 dq\" fei\351\243\236.txt" index da0f8ed..c1827f0 100644 --- "a/ff\f nak\025 dq\" fei\351\243\236.txt" +++ "b/ff\f nak\025 dq\" fei\351\243\236.txt" @@ -1 +1 @@ -a1 +a2 EOF ); SKIP: { if ($OSNAME eq 'cygwin' || $OSNAME eq 'msys') { skip "can't put backslashes in filenames on windows", 1; } Test::autofixup( name => "filename with backslash", topic_commits => [{"hack\\.txt" => "a1\n"}], unstaged => {"hack\\.txt" => "a2\n"}, exit_code => 0, log_want => <<'EOF' fixup! commit0 diff --git "a/hack\\.txt" "b/hack\\.txt" index da0f8ed..c1827f0 100644 --- "a/hack\\.txt" +++ "b/hack\\.txt" @@ -1 +1 @@ -a1 +a2 EOF ); } git-autofixup-0.004006/t/capture.t000066400000000000000000000015751454612743400166650ustar00rootroot00000000000000#!/usr/bin/perl use strict; use warnings FATAL => 'all'; use English qw(-no_match_vars); use Test::More; require './git-autofixup'; if ($OSNAME eq 'MSWin32') { plan skip_all => "Windows isn't supported, except with msys or Cygwin"; } plan tests => 3; sub test_capture { my %args = @_; my @cmd = ref $args{cmd} ? @{$args{cmd}} : ($args{cmd}); my $got = Autofixup::capture(@cmd); is_deeply($got, $args{want}, $args{name}); } test_capture( name => 'capture stdout, stderr, and exit_code', cmd => q(perl -e 'print STDERR "stderr\n"; print "stdout\n"; exit 3'), want => ["stdout\n", "stderr\n", 3], ); test_capture( name => 'capture echo command given as list', cmd => [qw(echo stdout)], want => ["stdout\n", '', 0], ); test_capture( name => 'capture echo with redirection', cmd => "echo stderr 1>&2", want => ['', "stderr\n", 0], ); git-autofixup-0.004006/t/implicit_upstream.t000077500000000000000000000207551454612743400207600ustar00rootroot00000000000000#!/usr/bin/perl # Test finding "upstream" commits. When making fixups we want to minimize the # number of commits we need to look at, and avoid making "fixups" (ie fixup # commits) for commits that could be reasonably expected to be pushed or for # commits that are likely to be considered outside of the topic branch. use strict; use warnings FATAL => 'all'; use Test::More; require './t/util.pl'; require './t/repo.pl'; require './git-autofixup'; Util::check_test_deps(4); plan tests => 4; # fast-forward from upstream # # Probably the most common case. # # o upstream # \ # o--o topic # { my $name = 'fast-forward from upstream'; my $wants = { fixup_log => <<'EOF', fixup! commit1 diff --git a/a b/a index 8737a60..ba81a56 100644 --- a/a +++ b/a @@ -1,3 +1,3 @@ -a1.1 -a2 +a1 +a2.1 a3 EOF staged => '', unstaged => '', }; eval { my $repo = Repo->new(); $repo->create_commits({a => "a1\na2\na3\n"}); my $upstream = $repo->current_commit_sha(); $repo->switch_to_downstream_branch('topic'); $repo->create_commits({a => "a1.1\na2\na3\n"}); my $topic = $repo->current_commit_sha(); $repo->write_change({a => "a1\na2.1\na3\n"}); my $upstreams = Autofixup::find_merge_bases(); my $ok = Util::upstreams_ok(want => [$upstream], got => $upstreams); if ($ok) { my $exit_code = $repo->autofixup(); $ok &&= Util::exit_code_ok(want => 0, got => $exit_code); $ok &&= Util::repo_state_ok($repo, $topic, $wants); } ok($ok, $name); }; if ($@) { diag($@); fail($name); } } # interactive rebase onto upstream, making a fixup from B for A # # o upstream # \ # A--B--C topic # { my $name = 'interactive rebase onto upstream'; my $wants = { fixup_log => <<'EOF', fixup! commit1 diff --git a/a b/a index 8737a60..ba81a56 100644 --- a/a +++ b/a @@ -1,3 +1,3 @@ -a1.1 -a2 +a1 +a2.1 a3 EOF staged => '', unstaged => '', }; eval { my $repo = Repo->new(); # upstream (commit0) $repo->create_commits({a => "a1\na2\na3\n"}); my $upstream = $repo->current_commit_sha(); # A (commit1) $repo->switch_to_downstream_branch('topic'); $repo->create_commits({a => "a1.1\na2\na3\n"}); my $topic = $repo->current_commit_sha(); # B (commit2) $repo->create_commits({a => "a1\na2.1\na3\n"}); # C $repo->create_commits({b => "b1\n"}); # Start an interactive rebase to edit commit B (which'll have commit2 # in its message). local $ENV{GIT_SEQUENCE_EDITOR} = q(perl -i -pe "/commit2/ && s/^pick/edit/"); Util::run("git rebase -q -i $upstream 2>/dev/null"); Util::run(qw(git reset HEAD^)); my $upstreams = Autofixup::find_merge_bases(); my $ok = Util::upstreams_ok(want => [$upstream], got => $upstreams); if ($ok) { my $exit_code = $repo->autofixup(); $ok &&= Util::exit_code_ok(want => 0, got => $exit_code); $ok &&= Util::repo_state_ok($repo, $topic, $wants); } ok($ok, $name); }; if ($@) { diag($@); fail($name); } } # fork-point and merge-base are different # # Here the upstream commit that topic originally diverged from is different # from the first ancestor that currently belongs to both topic and upstream, # due to upstream being rewound and rebuilt. We don't want to make fixups for # the fork-point since the user probably doesn't consider it part of the topic # branch at a conceptual level and by default `git rebase` excludes the # fork-point from the set of commits to be rewritten. # # B1--o upstream # \ # B0 fork-point: was previously part of upstream # \ # T0 topic # SKIP: { my $name = 'fork-point and merge-base are different'; if (!Util::git_version_gte('1.9.0')) { skip 'merge-base --fork-point only available in 1.9.0+', 1; } my $wants = { fixup_log => <<'EOF', fixup! commit2 diff --git a/a b/a index 8737a60..472f448 100644 --- a/a +++ b/a @@ -1,3 +1,3 @@ a1.1 -a2 +a2.1 a3 EOF staged => '', unstaged => '', }; eval { my $repo = Repo->new(); # upstream # # Create a commit to use as the fork-point for the topic branch, save # the SHA, then amend it so that the fork-point is no longer reachable # from master and create another commit on top. $repo->create_commits({a => "a1\na2\na3\n"}); my $fork_point = $repo->current_commit_sha(); Util::run(qw(git commit --amend -m), 'commit0, reworded'); # B1 $repo->create_commits({b => "b1\n"}); # o (commit1) # topic Util::run(qw(git checkout -q -b topic), $fork_point); $repo->set_upstream('topic', 'master'); $repo->create_commits({a => "a1.1\na2\na3\n"}); $repo->write_change({a => "a1.1\na2.1\na3\n"}); my $topic = $repo->current_commit_sha(); my $upstreams = Autofixup::find_merge_bases(); my $ok = Util::upstreams_ok(want => [$fork_point], got => $upstreams); if ($ok) { my $exit_code = $repo->autofixup(); $ok &&= Util::exit_code_ok(want => 0, got => $exit_code); $ok &&= Util::repo_state_ok($repo, $topic, $wants); } ok($ok, $name); }; if ($@) { diag($@); fail($name); } } # criss-cross merge and gc'd fork-point reflog # # Here's one way to get a criss-cross merge. If you have two branches (A and B, # here) that include the same merge commit, M0: # # C1-M0 A, B # / # C2 # # And then you amend that merge commit from one of the branches (A, in this # case), creating M1, you get the following topology. The important implication # for us is that commits 1 and 2 are both equally good merge bases of A and B, # and we don't want to create fixups for either of them or their ancestors. # (Here 'X' represents overlapping graph edges, not another commit.) # # C1---M1 A # \ / # X # / \ # C2---M0 B # # For this test we'll also amend B's merge commit and garbage-collect the # reflog so that M0 isn't simply used as B's fork-point from its tracking # branch A, forcing git-autofixup to fall back on the merge-bases C1 and C2. # # C1---M1 A # \ / # X # / \ # C2---M2---o B # { my $name = "criss-cross merge and gc'd fork point reflog"; my $wants = { fixup_log => <<'EOF', fixup! commit2 diff --git a/a b/a index 8737a60..472f448 100644 --- a/a +++ b/a @@ -1,3 +1,3 @@ a1.1 -a2 +a2.1 a3 diff --git a/b b/b index 0ef8a8e..b1710a1 100644 --- a/b +++ b/b @@ -1,3 +1,3 @@ b1.1 -b2 +b2.1 b3 EOF staged => '', unstaged => '', }; eval { my $repo = Repo->new(); # C1 Util::run(qw(git checkout -q -b A)); $repo->create_commits({a => "a1\na2\na3\n"}); my $c1 = $repo->current_commit_sha(); # C2 Util::run(qw(git checkout -q -b B master)); $repo->set_upstream('B', 'A'); $repo->create_commits({b => "b1\nb2\nb3\n"}); my $c2 = $repo->current_commit_sha(); # Merge A and B, so they're both pointing to the same merge commit. Util::run(qw(git merge --no-ff), '-m' => 'Merge A into B', 'A'); # M0 Util::run(qw(git checkout -q A)); Util::run(qw(git merge --ff-only B)); # fast-forward to M0 # Then ammend the merge commits for both branches and gc the reflog so # git can't tell what the original fork-point of B from A is. Util::run(qw(git commit --amend -m), 'Merge A into B, reworded for A'); # M1 Util::run(qw(git checkout -q B)); Util::run(qw(git commit --amend -m), 'Merge A into B, reworded for B'); # M2 Util::run('git -c gc.reflogExpire=now gc 2>/dev/null'); # topic $repo->create_commits({a => "a1.1\na2\na3\n", b => "b1.1\nb2\nb3\n"}); my $topic = $repo->current_commit_sha(); $repo->write_change({a => "a1.1\na2.1\na3\n", b => "b1.1\nb2.1\nb3\n"}); my @upstreams_got = sort(Autofixup::find_merge_bases()); my @upstreams_want = sort $c1, $c2; my $ok = Util::upstreams_ok(want => \@upstreams_want, got => \@upstreams_got); if ($ok) { my $exit_code = $repo->autofixup(); $ok &&= Util::exit_code_ok(want => 0, got => $exit_code); $ok &&= Util::repo_state_ok($repo, $topic, $wants); } ok($ok, $name); }; if ($@) { diag($@); fail($name); } } git-autofixup-0.004006/t/repo.pl000066400000000000000000000110411454612743400163240ustar00rootroot00000000000000package Repo; use strict; use warnings FATAL => 'all'; use Carp qw(croak); require './t/util.pl'; # Return a new Repo, which is a git repo initialized in a temp dir. # # By default the temp dir will be removed when it goes out of scope, so if you # want to be able to inspect a repo after a test fails, give `cleanup => 0`. sub new { my ($class, %args) = @_; my $self = {}; $self->{cleanup} = defined($args{cleanup}) ? $args{cleanup} : 1; bless $self, $class; $self->_init_env(); $self->_init_repo(); return $self; } sub _init_env { my $self = shift; my $orig_dir = Cwd::getcwd(); my $dir = File::Temp::tempdir(CLEANUP => $self->{cleanup}); chdir $dir or die "$!"; my %env = ( # Avoid loading user or global git config, since lots of options can # break our tests. HOME => $dir, XDG_CONFIG_HOME => $dir, GIT_CONFIG_NOSYSTEM => 'true', # In order to make commits, git requires an author identity. GIT_AUTHOR_NAME => 'A U Thor', GIT_AUTHOR_EMAIL => 'author@example.com', GIT_COMMITTER_NAME => 'C O Mitter', GIT_COMMITTER_EMAIL => 'committer@example.com', ); my %orig_env = (); for my $key (keys %env) { my $val = $env{$key}; $orig_env{$key} = $ENV{$key}; $ENV{$key} = $val; } $self->{dir} = $dir; $self->{orig_dir} = $orig_dir; $self->{orig_env} = \%orig_env; } sub _init_repo { my $self = shift; $self->{n_commits} = 0; # Number of commits created using create_commits() local $ENV{GIT_TEST_DEFAULT_INITIAL_BRANCH_NAME} = 'master'; Util::run('git init'); # git-autofixup needs a commit to exclude, since it uses the REVISION.. # syntax. This is that commit. my $filename = 'README'; Util::write_file($filename, "init\n"); Util::run("git add $filename"); Util::run(qw(git commit -m), "add $filename"); } # File::Temp will take care of cleaning tempdirs up at the end of the test # script. sub DESTROY { local $!; my $self = shift; chdir $self->{orig_dir} or die "change to orig working dir: $!"; for my $key (keys %{$self->{orig_env}}) { my $val = $self->{orig_env}{$key}; if (defined($val)) { $ENV{$key} = $val; } else { delete $ENV{$key}; } } } sub create_commits { my ($self, @commits) = @_; for my $commit (@commits) { $self->write_change($commit); $self->commit_if_dirty("commit" . $self->{n_commits}); $self->{n_commits} += 1; } } sub write_change { my ($self, $change) = @_; if (ref $change eq 'HASH') { while (my ($file, $contents) = each %{$change}) { Util::write_file($file, $contents); } } elsif (ref $change eq 'CODE') { &{$change}(); } } sub commit_if_dirty { my ($self, $msg) = @_; my $is_dirty = qx(git status -s); if ($is_dirty) { Util::run('git add -A'); Util::run(qw(git commit -am), $msg); } } sub log_since { my ($self, $revision) = @_; my $log = qx{git -c diff.noprefix=false log -p --format=%s ${revision}..}; if ($? != 0) { croak "git log: $!\n"; } return $log; } sub diff { my ($self, $revision) = @_; my $diff = qx{git -c diff.noprefix=false diff ${revision}}; if ($? != 0) { croak "git diff $!\n"; } return $diff; } sub current_commit_sha { my ($self, $dir) = @_; my $revision = qx{git rev-parse HEAD}; $? == 0 or croak "git rev-parse: $!"; chomp $revision; return $revision; } sub autofixup { my $self = shift; local @ARGV = @_; print "# git-autofixup ", join(' ', @ARGV), "\n"; return Autofixup::main(); } sub switch_to_downstream_branch { my ($self, $branch) = @_; my $tracking_branch = qx(git rev-parse --abbrev-ref HEAD) or croak "get tracking branch: $!"; chomp $tracking_branch; Util::run(qw(git checkout -q -b), $branch, '--track', $tracking_branch); } sub set_upstream { my ($self, $topic_branch, $upstream) = @_; my $cmd = "git branch --set-upstream-to $upstream >/dev/null 2>&1"; print "# $cmd\n"; system($cmd); if ($? >> 8 == 129) { # If git detects a bad option it exits with 129. For old versions of # git that don't have --set-upstream-to, fall back to --set-upstream, # which was removed in 2017. Util::run(qw(git branch --set-upstream), $topic_branch, $upstream); } elsif ($? != 0) { croak "`git branch --set-upstream-to` " . Util::child_error_desc($?); } return; } 1; git-autofixup-0.004006/t/test.pl000066400000000000000000000103441454612743400163430ustar00rootroot00000000000000# Long test functions that could conceivably be used in multiple .t files. package Test; use strict; use warnings FATAL => 'all'; use Carp qw(croak); use Cwd; use Test::More; require './t/util.pl'; require './t/repo.pl'; # Run autofixup() with each of the given strictness levels. sub autofixup_strict { my %args = @_; my $strict_levels = $args{strict} or croak "strictness levels not given"; delete $args{strict}; my $autofixup_opts = $args{autofixup_opts} || []; if (grep /^(--strict|-s)/, @{$autofixup_opts}) { croak "strict option already given"; } my $name = $args{name} || croak "name not given"; for my $strict (@{$strict_levels}) { $args{name} = "$name, strict=$strict"; $args{autofixup_opts} = ['-s' => $strict, @{$autofixup_opts}]; autofixup(%args); } } # autofixup initializes a git repo in a tempdir, creates given "upstream" and # "topic" commits, applies changes to the working directory, runs autofixup, # and compares wanted `git log` and `git diff` outputs to actual ones. # # Arguments are given as a hash: # name: test name or description # upstream_commits: sub or hash refs that must not be fixed up # topic_commits: sub or hash refs representing commits that can be fixed up # unstaged: sub or hashref of working directory changes # staged: sub or hashref of index changes # log_want: expected log output for new fixup commited # staged_want: expected log output for the staging area # unstaged_want: expected diff output for the working tree # autofixup_opts: command-line options to pass thru to autofixup # git_config: hashref of git config key/value pairs # # The upstream_commits and topic_commits arguments are heterogeneous lists of # sub and hash refs. Hash refs are interpreted as being maps of filenames to # contents to be written. If more flexibility is needed a subref can be given # to manipulate the working directory. sub autofixup { my %args = @_; my $name = defined($args{name}) ? $args{name} : croak "no test name given"; my $upstream_commits = $args{upstream_commits} || []; my $topic_commits = $args{topic_commits} || []; my $unstaged = defined($args{unstaged}) ? $args{unstaged} : croak "no unstaged changes given"; my $staged = $args{staged}; my $log_want = defined($args{log_want}) ? $args{log_want} : croak "wanted log output not given"; my $staged_want = $args{staged_want}; my $unstaged_want = $args{unstaged_want}; my $exit_code_want = $args{exit_code}; my $autofixup_opts = $args{autofixup_opts} || []; push @{$autofixup_opts}, '--exit-code'; my $git_config = $args{git_config} || {}; if (!$upstream_commits && !$topic_commits) { croak "no upstream or topic commits given"; } if (exists $args{strict}) { croak "strict key given; use test_autofixup_strict instead"; } local $ENV{GIT_CONFIG_COUNT} = scalar keys %$git_config; my $git_config_env = Util::git_config_env_vars($git_config); local (@ENV{keys %$git_config_env}); for my $k (keys %$git_config_env) { $ENV{$k} = $git_config_env->{$k}; } eval { my $repo = Repo->new(); $repo->create_commits(@$upstream_commits); my $upstream_rev = $repo->current_commit_sha(); $repo->create_commits(@$topic_commits); my $pre_fixup_rev = $repo->current_commit_sha(); if (defined($staged)) { $repo->write_change($staged); # We're at the repo root, so using -A will change everything even # in pre-v2 versions of git. See git commit 808d3d717e8. Util::run(qw(git add -A)); } $repo->write_change($unstaged); Util::run('git', '--no-pager', 'log', "--format='%h %s'", "${upstream_rev}.."); my $exit_code_got = $repo->autofixup(@{$autofixup_opts}, $upstream_rev); my $ok = Util::exit_code_ok(want => $exit_code_want, got => $exit_code_got); my $wants = { fixup_log => $log_want, staged => $staged_want, unstaged => $unstaged_want, }; $ok &&= Util::repo_state_ok($repo, $pre_fixup_rev, $wants); ok($ok, $name); }; if ($@) { diag($@); fail($name); } return; } git-autofixup-0.004006/t/util.pl000066400000000000000000000122041454612743400163360ustar00rootroot00000000000000package Util; use strict; use warnings FATAL => 'all'; use Carp qw(croak); use Cwd; use English qw(-no_match_vars); use Test::More; require './t/repo.pl'; require './git-autofixup'; sub check_test_deps { if ($OSNAME eq 'MSWin32') { plan skip_all => "Windows isn't supported, except with msys or Cygwin"; } elsif (!git_version_gte('1.7.4')) { plan skip_all => 'git version 1.7.4+ required'; } elsif ($OSNAME eq 'cygwin' && is_git_for_windows()) { plan skip_all => "Can't use Git for Windows with a perl for Cygwin"; } } # Return true if at least the given version of git, in X.Y.Z format, is # available. sub git_version_gte { my $want = shift; my @wants = split /\./, $want; if (@wants != 3) { die "unexpected version format: $want\n"; } my ($want_x, $want_y, $want_z) = @wants; my $stdout = qx{git --version}; return if $? != 0; my ($x, $y, $z) = $stdout =~ /(\d+)\.(\d+)(?:\.(\d+))?/; defined $x or die "unexpected output from git: $stdout"; $z = defined $z ? $z : 0; my $cmp = $x <=> $want_x || $y <=> $want_y || $z <=> $want_z; return $cmp >= 0; } sub is_git_for_windows { my $version = qx{git --version}; return $version =~ /\.(?:windows|msysgit)\./i; } # Convert a hashref of git config key-value pairs to a hashref of # GIT_CONFIG_{KEY,VALUE}_ pairs suitable for setting as environment # variables. # # For example: # # > git_config_env_vars({'diff.mnemonicPrefix' => 'true'}) # { # GIT_CONFIG_KEY_0 => 'diff.mnemonicPrefix', # GIT_CONFIG_VALUE_0 => 'true', # } sub git_config_env_vars { my $git_config = shift; my %env = (); my $i = 0; for my $k (sort(keys %$git_config)) { $env{"GIT_CONFIG_KEY_$i"} = $k; $env{"GIT_CONFIG_VALUE_$i"} = $git_config->{$k}; $i++; } return \%env; } # Take wanted and actual autofixup exit codes as a hash with keys ('want', # 'got') and return true if want and got are equal or if want is undefined. # Print a TAP diagnostic if they aren't ok. # # eg: exit_code_got(want => 3, got => 2) # # Params are taken as a hash since the order matters and it seems difficult to # get the order right if the args aren't named. sub exit_code_ok { my %args = @_; defined $args{got} or croak "got exit code is undefined"; if (defined $args{want} && $args{want} != $args{got}) { diag("exit_code_want=$args{want},exit_code_got=$args{got}"); return 0; } return 1; } # Take wanted and actual listrefs of upstream SHAs as a hash with keys ('want', # 'got') and return true if want and got are equal. Print a TAP diagnostic if # they aren't ok. # # eg: exit_code_got(want => 3, got => 2) sub upstreams_ok { my %args = @_; defined $args{want} or croak 'wanted upstream list must be given'; defined $args{got} or croak 'actual upstream list must be given'; my @wants = @{$args{want}}; my @gots = @{$args{got}}; my $max_len = @wants > @gots ? @wants : @gots; my $ok = 1; for my $i (0..$max_len - 1) { my $want = defined $wants[$i] ? $wants[$i] : ''; my $got = defined $gots[$i] ? $gots[$i] : ''; if (!$want || !$got || $want ne $got) { diag("upstream mismatch,i=$i,want=$want,got=$got"); $ok = 0; } } return $ok; } # Return whether the repo state is as desired and print TAP diagnostics if not. # # Parameters: # - a Repo object # - a SHA for the last topic branch commit before any fixups were made # - a hashref of `git log` outputs, for `fixups`, `staged`, and `unstaged`. If # `staged` isn't given, then it's assumed that there shouldn't be any staged # changes. sub repo_state_ok { my ($repo, $pre_fixup_rev, $wants) = @_; my $ok = 1; for my $key (qw(fixup_log staged unstaged)) { next if (!defined $wants->{$key}); my $want = $wants->{$key}; my $got; if ($key eq 'fixup_log') { $got = $repo->log_since($pre_fixup_rev); } elsif ($key eq 'staged') { $got = $repo->diff('--cached'); } elsif ($key eq 'unstaged') { $got = $repo->diff('HEAD'); } if ($got ne $want) { diag("${key}_got=<{staged})) { my $got = $repo->diff('--cached'); if ($got) { diag("staged_got=<> 8); } } sub write_file { my ($filename, $contents) = @_; open(my $fh, '>', $filename) or croak "$!"; print {$fh} $contents or croak "$!"; close $fh or croak "$!"; } 1; git-autofixup-0.004006/xt/000077500000000000000000000000001454612743400152155ustar00rootroot00000000000000git-autofixup-0.004006/xt/deps.t000077500000000000000000000046261454612743400163500ustar00rootroot00000000000000#!/usr/bin/perl # Check that the dependencies in Makefile.PL match what we can extract from the # code. use strict; use warnings FATAL => 'all'; use Test::More tests => 2; use Data::Dumper; $Data::Dumper::Sortkeys = 1; my @ignore = qw(App::Git::Autofixup); # Try to find dependencies brought in with `use` in the given files and return # them as a hashref in MakeMaker format, { => , ...}. # # scan_deps() deosn't try to extract `require` statements, on the assumption # that if you're using require you're already thinking carefully about # dependencies and will add the required module to the dependency list as # appropriate. Dealing with every special case isn't feasible here. sub scan_deps { my %deps; for my $filename (@_) { open my $fh, '<', $filename or die "scan $filename: $!"; for my $line (<$fh>) { if ($line =~ /^\s*use\s+([a-zA-Z]\S+)(?:\s*)?([v0-9]\S*)?.*;$/) { my ($module, $version) = ($1, $2); $deps{$module} = defined($version) ? $version : 0; } } } for my $k (@ignore) { delete $deps{$k}; } return \%deps; } sub is_hashes_equal { my ($h_a, $h_b) = @_; for my $k (keys %$h_a, keys %$h_b) { if (!exists($h_a->{$k}) || !exists($h_b->{$k})) { return 0; } # Convert to strings. my $a = '' . $h_a->{$k}; my $b = '' . $h_b->{$k}; if ($a ne $b) { return 0; } } return 1; } sub get_deps { return scan_deps(qw(git-autofixup lib/App/Git/Autofixup.pm)); } sub get_test_deps { my $deps = get_deps(); my $test_deps = scan_deps(glob('t/*.t t/*.pl xt/*.t xt/*.pl')); for my $k (keys %$deps) { delete $test_deps->{$k}; } return $test_deps; } require './Makefile.PL'; { my $want = get_deps(); $want->{'Pod::Usage'} = 0; my $got = Makefile::get_deps(); my $ok = is_hashes_equal($got, $want); if (!$ok) { diag("got: " . Dumper($got)); diag("want " . Dumper($want)); } ok($ok, 'Makefile.PL dependencies seem accurate'); } { my $want = get_test_deps(); $want->{'Test::Pod'} = '1.00'; my $got = Makefile::get_test_deps(); my $ok = is_hashes_equal($got, $want); if (!$ok) { diag("got: " . Dumper($got)); diag("want " . Dumper($want)); } ok($ok, 'Makefile.PL test dependencies seem accurate'); } git-autofixup-0.004006/xt/pod.t000066400000000000000000000003401454612743400161610ustar00rootroot00000000000000use strict; use warnings FATAL => 'all'; use Test::More; eval "use Test::Pod 1.00"; plan skip_all => "Test::Pod 1.00 required for testing POD" if $@; all_pod_files_ok(qw(README.pod git-autofixup lib/App/Git/Autofixup.pm)); git-autofixup-0.004006/xt/precommit.t000066400000000000000000000010211454612743400173730ustar00rootroot00000000000000use strict; use warnings FATAL => 'all'; use Test::More tests => 2; use File::Temp; use App::Git::Autofixup; require './git-autofixup'; { my $script_version = $Autofixup::VERSION; my $stub_module_version = $App::Git::Autofixup::VERSION; is($script_version, $stub_module_version, "versions agree"); } { my $tmp = File::Temp->new(); my $tmp_name = $tmp->filename(); system("perldoc -u git-autofixup >$tmp_name"); system("diff -u $tmp_name README.pod"); ok($? == 0, 'README.pod is up-to-date'); } git-autofixup-0.004006/xt/util.t000066400000000000000000000012461454612743400163620ustar00rootroot00000000000000#!/usr/bin/perl use strict; use warnings FATAL => 'all'; use Test::More tests => 1; require './t/util.pl'; sub test_git_config_env_vars_converts_multiple_pairs { my $name = "git_config_env_vars converts multiple pairs"; my $git_config = { 'diff.mnemonicPrefix' => 'true', 'diff.external' => 'vimdiff', }; my $got = Util::git_config_env_vars($git_config); my $want = { GIT_CONFIG_KEY_0 => 'diff.external', GIT_CONFIG_VALUE_0 => 'vimdiff', GIT_CONFIG_KEY_1 => 'diff.mnemonicPrefix', GIT_CONFIG_VALUE_1 => 'true', }; is_deeply($got, $want, $name) } test_git_config_env_vars_converts_multiple_pairs();