From 98580d32ad358197ae7b20c5a7c2e961fdeeb0ae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hannes=20K=C3=B6rber?= Date: Mon, 29 Nov 2021 01:48:33 +0100 Subject: [PATCH 01/68] Run e2e tests in tmpfs --- Justfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Justfile b/Justfile index 28acf3e..721426d 100644 --- a/Justfile +++ b/Justfile @@ -30,7 +30,7 @@ e2e-venv: test-e2e: e2e-venv release cd ./e2e_tests \ && . ./venv/bin/activate \ - && python -m pytest . + && TMPDIR=/dev/shm python -m pytest . update-dependencies: @cd ./depcheck \ From 6e6050c71bc7964c06f768372308370f416b0ac4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hannes=20K=C3=B6rber?= Date: Mon, 29 Nov 2021 02:08:15 +0100 Subject: [PATCH 02/68] Fix hashing of directory --- e2e_tests/helpers.py | 31 +++++++++++++++++++++++++++++-- 1 file changed, 29 insertions(+), 2 deletions(-) diff --git a/e2e_tests/helpers.py b/e2e_tests/helpers.py index 810c158..2cd507c 100644 --- a/e2e_tests/helpers.py +++ b/e2e_tests/helpers.py @@ -53,8 +53,35 @@ def checksum_directory(path): raise f"{path} not found" def get_stat_hash(path): - stat = bytes(str(os.stat(path).__hash__()), "ascii") - return stat + checksum = hashlib.md5() + + # A note about bytes(). You may think that it converts something to + # bytes (akin to str()). But it actually creates a list of zero bytes + # with the length specified by the parameter. + # + # This is kinda couterintuitive to me: + # + # str(5) => '5' + # bytes(5) => b'\x00\x00\x00\x00\x00' + def int_to_bytes(i): + return i.to_bytes((i.bit_length() + 7) // 8, byteorder="big") + + # lstat() instead of stat() so symlinks are not followed. So symlinks + # are treated as-is and will also be checked for changes. + stat = os.lstat(path) + + # Note that the list of attributes does not include any timings except + # mtime. + for s in [ + stat.st_mode, # type & permission bits + stat.st_ino, # inode + stat.st_uid, + stat.st_gid, + # it's a float in seconds, so this gives us ~1us precision + int(stat.st_mtime * 1e6), + ]: + checksum.update(int_to_bytes(s)) + return checksum.digest() for root, dirs, files in os.walk(path): for file in files: From d39df526de2ebf2d4fc9e4dc7a5ea9be3d043e47 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hannes=20K=C3=B6rber?= Date: Mon, 29 Nov 2021 02:10:58 +0100 Subject: [PATCH 03/68] Skip unneeded directory hashing in e2e tests --- e2e_tests/test_worktree_clean.py | 12 ------------ e2e_tests/test_worktrees.py | 18 ------------------ 2 files changed, 30 deletions(-) diff --git a/e2e_tests/test_worktree_clean.py b/e2e_tests/test_worktree_clean.py index 79cc00f..fa916e1 100644 --- a/e2e_tests/test_worktree_clean.py +++ b/e2e_tests/test_worktree_clean.py @@ -16,8 +16,6 @@ def test_worktree_clean(): def test_worktree_clean_refusal_no_tracking_branch(): with TempGitRepositoryWorktree() as base_dir: - before = checksum_directory(base_dir) - cmd = grm(["wt", "add", "test"], cwd=base_dir) assert cmd.returncode == 0 @@ -32,8 +30,6 @@ def test_worktree_clean_refusal_no_tracking_branch(): def test_worktree_clean_refusal_uncommited_changes_new_file(): with TempGitRepositoryWorktree() as base_dir: - before = checksum_directory(base_dir) - cmd = grm(["wt", "add", "test", "--track", "origin/test"], cwd=base_dir) assert cmd.returncode == 0 @@ -50,8 +46,6 @@ def test_worktree_clean_refusal_uncommited_changes_new_file(): def test_worktree_clean_refusal_uncommited_changes_changed_file(): with TempGitRepositoryWorktree() as base_dir: - before = checksum_directory(base_dir) - cmd = grm(["wt", "add", "test", "--track", "origin/test"], cwd=base_dir) assert cmd.returncode == 0 @@ -68,8 +62,6 @@ def test_worktree_clean_refusal_uncommited_changes_changed_file(): def test_worktree_clean_refusal_uncommited_changes_cleand_file(): with TempGitRepositoryWorktree() as base_dir: - before = checksum_directory(base_dir) - cmd = grm(["wt", "add", "test", "--track", "origin/test"], cwd=base_dir) assert cmd.returncode == 0 @@ -88,8 +80,6 @@ def test_worktree_clean_refusal_uncommited_changes_cleand_file(): def test_worktree_clean_refusal_commited_changes(): with TempGitRepositoryWorktree() as base_dir: - before = checksum_directory(base_dir) - cmd = grm(["wt", "add", "test", "--track", "origin/test"], cwd=base_dir) assert cmd.returncode == 0 @@ -108,8 +98,6 @@ def test_worktree_clean_refusal_commited_changes(): def test_worktree_clean_refusal_tracking_branch_mismatch(): with TempGitRepositoryWorktree() as base_dir: - before = checksum_directory(base_dir) - cmd = grm(["wt", "add", "test", "--track", "origin/test"], cwd=base_dir) assert cmd.returncode == 0 diff --git a/e2e_tests/test_worktrees.py b/e2e_tests/test_worktrees.py index 6ddf641..4a63cd0 100644 --- a/e2e_tests/test_worktrees.py +++ b/e2e_tests/test_worktrees.py @@ -7,8 +7,6 @@ import git def test_worktree_add_simple(): with TempGitRepositoryWorktree() as base_dir: - before = checksum_directory(base_dir) - cmd = grm(["wt", "add", "test"], cwd=base_dir) assert cmd.returncode == 0 @@ -25,8 +23,6 @@ def test_worktree_add_simple(): def test_worktree_add_with_tracking(): with TempGitRepositoryWorktree() as base_dir: - before = checksum_directory(base_dir) - cmd = grm(["wt", "add", "test", "--track", "origin/test"], cwd=base_dir) print(cmd.stderr) assert cmd.returncode == 0 @@ -61,8 +57,6 @@ def test_worktree_delete(): def test_worktree_delete_refusal_no_tracking_branch(): with TempGitRepositoryWorktree() as base_dir: - before = checksum_directory(base_dir) - cmd = grm(["wt", "add", "test"], cwd=base_dir) assert cmd.returncode == 0 @@ -79,8 +73,6 @@ def test_worktree_delete_refusal_no_tracking_branch(): def test_worktree_delete_refusal_uncommited_changes_new_file(): with TempGitRepositoryWorktree() as base_dir: - before = checksum_directory(base_dir) - cmd = grm(["wt", "add", "test", "--track", "origin/test"], cwd=base_dir) assert cmd.returncode == 0 @@ -99,8 +91,6 @@ def test_worktree_delete_refusal_uncommited_changes_new_file(): def test_worktree_delete_refusal_uncommited_changes_changed_file(): with TempGitRepositoryWorktree() as base_dir: - before = checksum_directory(base_dir) - cmd = grm(["wt", "add", "test", "--track", "origin/test"], cwd=base_dir) assert cmd.returncode == 0 @@ -119,8 +109,6 @@ def test_worktree_delete_refusal_uncommited_changes_changed_file(): def test_worktree_delete_refusal_uncommited_changes_deleted_file(): with TempGitRepositoryWorktree() as base_dir: - before = checksum_directory(base_dir) - cmd = grm(["wt", "add", "test", "--track", "origin/test"], cwd=base_dir) assert cmd.returncode == 0 @@ -141,8 +129,6 @@ def test_worktree_delete_refusal_uncommited_changes_deleted_file(): def test_worktree_delete_refusal_commited_changes(): with TempGitRepositoryWorktree() as base_dir: - before = checksum_directory(base_dir) - cmd = grm(["wt", "add", "test", "--track", "origin/test"], cwd=base_dir) assert cmd.returncode == 0 @@ -163,8 +149,6 @@ def test_worktree_delete_refusal_commited_changes(): def test_worktree_delete_refusal_tracking_branch_mismatch(): with TempGitRepositoryWorktree() as base_dir: - before = checksum_directory(base_dir) - cmd = grm(["wt", "add", "test", "--track", "origin/test"], cwd=base_dir) assert cmd.returncode == 0 @@ -185,8 +169,6 @@ def test_worktree_delete_refusal_tracking_branch_mismatch(): def test_worktree_delete_force_refusal(): with TempGitRepositoryWorktree() as base_dir: - before = checksum_directory(base_dir) - cmd = grm(["wt", "add", "test"], cwd=base_dir) assert cmd.returncode == 0 From d26a76d064c952f710113118f76be1c10a3c19ee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hannes=20K=C3=B6rber?= Date: Tue, 30 Nov 2021 09:49:08 +0100 Subject: [PATCH 04/68] Add GPLv3 license file --- LICENSE | 674 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 674 insertions(+) create mode 100644 LICENSE diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..f288702 --- /dev/null +++ b/LICENSE @@ -0,0 +1,674 @@ + GNU GENERAL PUBLIC LICENSE + Version 3, 29 June 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU General Public License is a free, copyleft license for +software and other kinds of works. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +the GNU General Public License is intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. We, the Free Software Foundation, use the +GNU General Public License for most of our software; it applies also to +any other work released this way by its authors. You can apply it to +your programs, too. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + To protect your rights, we need to prevent others from denying you +these rights or asking you to surrender the rights. Therefore, you have +certain responsibilities if you distribute copies of the software, or if +you modify it: responsibilities to respect the freedom of others. + + For example, if you distribute copies of such a program, whether +gratis or for a fee, you must pass on to the recipients the same +freedoms that you received. You must make sure that they, too, receive +or can get the source code. And you must show them these terms so they +know their rights. + + Developers that use the GNU GPL protect your rights with two steps: +(1) assert copyright on the software, and (2) offer you this License +giving you legal permission to copy, distribute and/or modify it. + + For the developers' and authors' protection, the GPL clearly explains +that there is no warranty for this free software. For both users' and +authors' sake, the GPL requires that modified versions be marked as +changed, so that their problems will not be attributed erroneously to +authors of previous versions. + + Some devices are designed to deny users access to install or run +modified versions of the software inside them, although the manufacturer +can do so. This is fundamentally incompatible with the aim of +protecting users' freedom to change the software. The systematic +pattern of such abuse occurs in the area of products for individuals to +use, which is precisely where it is most unacceptable. Therefore, we +have designed this version of the GPL to prohibit the practice for those +products. If such problems arise substantially in other domains, we +stand ready to extend this provision to those domains in future versions +of the GPL, as needed to protect the freedom of users. + + Finally, every program is threatened constantly by software patents. +States should not allow patents to restrict development and use of +software on general-purpose computers, but in those that do, we wish to +avoid the special danger that patents applied to a free program could +make it effectively proprietary. To prevent this, the GPL assures that +patents cannot be used to render the program non-free. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Use with the GNU Affero General Public License. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU Affero General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the special requirements of the GNU Affero General Public License, +section 13, concerning interaction through a network will apply to the +combination as such. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU General Public License from time to time. Such new versions will +be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If the program does terminal interaction, make it output a short +notice like this when it starts in an interactive mode: + + Copyright (C) + This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. + This is free software, and you are welcome to redistribute it + under certain conditions; type `show c' for details. + +The hypothetical commands `show w' and `show c' should show the appropriate +parts of the General Public License. Of course, your program's commands +might be different; for a GUI interface, you would use an "about box". + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU GPL, see +. + + The GNU General Public License does not permit incorporating your program +into proprietary programs. If your program is a subroutine library, you +may consider it more useful to permit linking proprietary applications with +the library. If this is what you want to do, use the GNU Lesser General +Public License instead of this License. But first, please read +. From d5eddc447686a8e73f052b670e9e98ad5c73f48b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hannes=20K=C3=B6rber?= Date: Tue, 30 Nov 2021 10:13:16 +0100 Subject: [PATCH 05/68] Add contributing guidelines --- CONTRIBUTING.md | 49 ++++++++++++++++++++++++++++++++++++++++ docs/src/SUMMARY.md | 1 + docs/src/contributing.md | 1 + 3 files changed, 51 insertions(+) create mode 100644 CONTRIBUTING.md create mode 120000 docs/src/contributing.md diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..c358044 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,49 @@ +# Contributing + +GRM is still in very early development. I started GRM mainly to scratch my own +itches (and am heavily dogfooding it). If you have a new use case for GRM, go +for it! + +The branching strategy is a simplified +[git-flow](https://nvie.com/posts/a-successful-git-branching-model/). + +* `master` is the "production" branch. Each commit is a new release. +* `develop` is the branch where new stuff is coming in. +* feature branches branch off of `develop` and merge back into it. + +So to contribute, just fork the repo and create a pull request against +`develop`. If you plan bigger changes, please consider opening an issue first, +so we can discuss it. + +If you want, add yourself to the `CONTRIBUTORS` file in your pull request. + +## Code formatting + +For Rust, just use `cargo fmt`. For Python, use +[black](https://github.com/psf/black). I'd rather not spend any effort in +configuring the formatters (not possible for black anyway). + +## Tooling + +GRM uses [`just`](https://github.com/casey/just) as a command runner. See +[here](https://github.com/casey/just#installation) for installation +instructions (it's most likely just a simple `cargo install just`). + +## Testing + +There are two distinct test suites: One for unit test (`just test-unit`) and +integration tests (`just test-integration`) that is part of the rust crate, and +a separate e2e test suite in python (`just test-e2e`). + +To run all tests, run `just test`. + +When contributing, consider whether it makes sense to add tests that to prevent +regressions in the future. When fixing bugs, it makes sense to add tests that +expose the wrong behaviour beforehand. + +## Documentation + +The documentation lives in `docs` and uses +[mdBook](https://github.com/rust-lang/mdBook). Please document new user-facing +features here! + diff --git a/docs/src/SUMMARY.md b/docs/src/SUMMARY.md index 5dc3e45..5af1148 100644 --- a/docs/src/SUMMARY.md +++ b/docs/src/SUMMARY.md @@ -5,3 +5,4 @@ - [Repository trees](./repos.md) - [Git Worktrees](./worktrees.md) - [FAQ](./faq.md) +- [Contributing](./contributing.md) diff --git a/docs/src/contributing.md b/docs/src/contributing.md new file mode 120000 index 0000000..f939e75 --- /dev/null +++ b/docs/src/contributing.md @@ -0,0 +1 @@ +../../CONTRIBUTING.md \ No newline at end of file From 14b200ee3defc4b99826c58bde2ced4d09d74650 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hannes=20K=C3=B6rber?= Date: Tue, 30 Nov 2021 22:51:28 +0100 Subject: [PATCH 06/68] Add nonnominandus to contributors --- CONTRIBUTORS | 1 + 1 file changed, 1 insertion(+) create mode 100644 CONTRIBUTORS diff --git a/CONTRIBUTORS b/CONTRIBUTORS new file mode 100644 index 0000000..c345969 --- /dev/null +++ b/CONTRIBUTORS @@ -0,0 +1 @@ +nonnominandus From ed06c52c8f147fce83709b8246e6b560a274d336 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hannes=20K=C3=B6rber?= Date: Wed, 1 Dec 2021 20:03:25 +0100 Subject: [PATCH 07/68] Add e2e test for finding in tree with broken repos --- e2e_tests/test_repos_find.py | 74 ++++++++++++++++++++++++++++++++++++ 1 file changed, 74 insertions(+) diff --git a/e2e_tests/test_repos_find.py b/e2e_tests/test_repos_find.py index 0608e28..82113ba 100644 --- a/e2e_tests/test_repos_find.py +++ b/e2e_tests/test_repos_find.py @@ -158,3 +158,77 @@ def test_repos_find_in_root(): someremote = [r for r in repo1["remotes"] if r["name"] == "otherremote"][0] assert set(origin.keys()) == {"name", "type", "url"} assert someremote["type"] == "file" + +def test_repos_find_with_invalid_repo(): + with tempfile.TemporaryDirectory() as tmpdir: + shell( + f""" + cd {tmpdir} + mkdir repo1 + ( + cd ./repo1 + git init + echo test > test + git add test + git commit -m "commit1" + git remote add origin https://example.com/repo2.git + git remote add someremote ssh://example.com/repo2.git + ) + mkdir repo2 + ( + cd ./repo2 + git init + git co -b main + echo test > test + git add test + git commit -m "commit1" + git remote add origin https://example.com/repo2.git + ) + mkdir broken_repo + ( + cd broken_repo + echo "broken" > .git + ) + """ + ) + + cmd = grm(["repos", "find", tmpdir]) + assert cmd.returncode == 0 + assert "broken" in cmd.stderr + + output = toml.loads(cmd.stdout) + + assert isinstance(output, dict) + assert set(output.keys()) == {"trees"} + assert isinstance(output["trees"], list) + assert len(output["trees"]) == 1 + for tree in output["trees"]: + assert set(tree.keys()) == {"root", "repos"} + assert tree["root"] == tmpdir + assert isinstance(tree["repos"], list) + assert len(tree["repos"]) == 2 + + repo1 = [r for r in tree["repos"] if r["name"] == "repo1"][0] + assert repo1["worktree_setup"] is False + assert isinstance(repo1["remotes"], list) + assert len(repo1["remotes"]) == 2 + + origin = [r for r in repo1["remotes"] if r["name"] == "origin"][0] + assert set(origin.keys()) == {"name", "type", "url"} + assert origin["type"] == "https" + assert origin["url"] == "https://example.com/repo2.git" + + someremote = [r for r in repo1["remotes"] if r["name"] == "someremote"][0] + assert set(origin.keys()) == {"name", "type", "url"} + assert someremote["type"] == "ssh" + assert someremote["url"] == "ssh://example.com/repo2.git" + + repo2 = [r for r in tree["repos"] if r["name"] == "repo2"][0] + assert repo2["worktree_setup"] is False + assert isinstance(repo1["remotes"], list) + assert len(repo2["remotes"]) == 1 + + origin = [r for r in repo2["remotes"] if r["name"] == "origin"][0] + assert set(origin.keys()) == {"name", "type", "url"} + assert origin["type"] == "https" + assert origin["url"] == "https://example.com/repo2.git" From da601c2d5fd746e4aef53e005c43c7b56a312cfc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hannes=20K=C3=B6rber?= Date: Wed, 1 Dec 2021 20:12:11 +0100 Subject: [PATCH 08/68] Always use color in pytest --- Justfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Justfile b/Justfile index 721426d..1ab9c1d 100644 --- a/Justfile +++ b/Justfile @@ -30,7 +30,7 @@ e2e-venv: test-e2e: e2e-venv release cd ./e2e_tests \ && . ./venv/bin/activate \ - && TMPDIR=/dev/shm python -m pytest . + && TMPDIR=/dev/shm python -m pytest --color=yes . update-dependencies: @cd ./depcheck \ From f0c8805cf3dee4fc48237d6dffd9504d7574c413 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hannes=20K=C3=B6rber?= Date: Tue, 30 Nov 2021 18:11:33 +0100 Subject: [PATCH 09/68] Refactor This refactors a huge chunk of the code base to make it more maintainable. Main points: * Proper separation between bin and lib. Bin handles argument parsing & validation and (most of) the output. Lib provides interfaces for all opreations. * Before, libgit2 internals were literred throughout the codebase, mainly the `Repository` struct and `git2::Error` in Results. They library is now properly wrapped in `repo.rs`, which exposes only the required functionality. It also standardizes the Error messages (they're just Strings for now) and handles stuff like the copious usage of Options to wrap maybe-invalid-utf-8 values. The program will still panic on non-utf-8 Strings e.g. in git remotes, but I guess this is acceptable. If you actually manage to hit this case, I promise I'll fix it :D * Many unwraps() are now gone and properly handled. * The table printing functionality is now confined to `table.rs`, instead of passing tables as parameters through the whole program. --- Cargo.toml | 2 +- src/config.rs | 36 +- src/{ => grm}/cmd.rs | 0 src/grm/main.rs | 278 +++++++++ src/lib.rs | 1314 +++++++++--------------------------------- src/main.rs | 5 - src/repo.rs | 953 +++++++++++++++++++++++------- src/table.rs | 311 ++++++++++ tests/repo.rs | 8 +- 9 files changed, 1629 insertions(+), 1278 deletions(-) rename src/{ => grm}/cmd.rs (100%) create mode 100644 src/grm/main.rs delete mode 100644 src/main.rs create mode 100644 src/table.rs diff --git a/Cargo.toml b/Cargo.toml index fe81561..8a93b6e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -32,7 +32,7 @@ path = "src/lib.rs" [[bin]] name = "grm" -path = "src/main.rs" +path = "src/grm/main.rs" [dependencies] diff --git a/src/config.rs b/src/config.rs index 7a0caec..efcf6e6 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,18 +1,48 @@ use serde::{Deserialize, Serialize}; -use super::repo::Repo; +use super::repo::RepoConfig; #[derive(Debug, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct Config { - pub trees: Vec, + pub trees: Trees, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct Trees(Vec); + +impl Trees { + pub fn to_config(self) -> Config { + Config { trees: self } + } + + pub fn from_vec(vec: Vec) -> Self { + Trees(vec) + } + + pub fn as_vec(self) -> Vec { + self.0 + } + + pub fn as_vec_ref(&self) -> &Vec { + self.0.as_ref() + } +} + +impl Config { + pub fn as_toml(&self) -> Result { + match toml::to_string(self) { + Ok(toml) => Ok(toml), + Err(error) => Err(error.to_string()), + } + } } #[derive(Debug, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct Tree { pub root: String, - pub repos: Option>, + pub repos: Option>, } pub fn read_config(path: &str) -> Result { diff --git a/src/cmd.rs b/src/grm/cmd.rs similarity index 100% rename from src/cmd.rs rename to src/grm/cmd.rs diff --git a/src/grm/main.rs b/src/grm/main.rs new file mode 100644 index 0000000..203d82c --- /dev/null +++ b/src/grm/main.rs @@ -0,0 +1,278 @@ +use std::path::Path; +use std::process; + +mod cmd; + +use grm::config; +use grm::output::*; + +fn main() { + let opts = cmd::parse(); + + match opts.subcmd { + cmd::SubCommand::Repos(repos) => match repos.action { + cmd::ReposAction::Sync(sync) => { + let config = match config::read_config(&sync.config) { + Ok(config) => config, + Err(error) => { + print_error(&error); + process::exit(1); + } + }; + match grm::sync_trees(config) { + Ok(success) => { + if !success { + process::exit(1) + } + } + Err(error) => { + print_error(&format!("Error syncing trees: {}", error)); + process::exit(1); + } + } + } + cmd::ReposAction::Status(args) => match &args.config { + Some(config_path) => { + let config = match config::read_config(config_path) { + Ok(config) => config, + Err(error) => { + print_error(&error); + process::exit(1); + } + }; + match grm::table::get_status_table(config) { + Ok((tables, errors)) => { + for table in tables { + println!("{}", table); + } + for error in errors { + print_error(&format!("Error: {}", error)); + } + } + Err(error) => print_error(&format!("Error getting status: {}", error)), + } + } + None => { + let dir = match std::env::current_dir() { + Ok(dir) => dir, + Err(error) => { + print_error(&format!("Could not open current directory: {}", error)); + process::exit(1); + } + }; + + match grm::table::show_single_repo_status(&dir) { + Ok((table, warnings)) => { + println!("{}", table); + for warning in warnings { + print_warning(&warning); + } + } + Err(error) => print_error(&format!("Error getting status: {}", error)), + } + } + }, + cmd::ReposAction::Find(find) => { + let path = Path::new(&find.path); + if !path.exists() { + print_error(&format!("Path \"{}\" does not exist", path.display())); + process::exit(1); + } + if !path.is_dir() { + print_error(&format!("Path \"{}\" is not a directory", path.display())); + process::exit(1); + } + + let path = match path.canonicalize() { + Ok(path) => path, + Err(error) => { + print_error(&format!( + "Failed to canonicalize path \"{}\". This is a bug. Error message: {}", + &path.display(), + error + )); + process::exit(1); + } + }; + + let found_repos = match grm::find_in_tree(&path) { + Ok(repos) => repos, + Err(error) => { + print_error(&error); + process::exit(1); + } + }; + + let trees = grm::config::Trees::from_vec(vec![found_repos]); + if trees.as_vec_ref().iter().all(|t| match &t.repos { + None => false, + Some(r) => r.is_empty(), + }) { + print_warning("No repositories found"); + } else { + let config = trees.to_config(); + + let toml = match config.as_toml() { + Ok(toml) => toml, + Err(error) => { + print_error(&format!("Failed converting config to TOML: {}", &error)); + process::exit(1); + } + }; + + print!("{}", toml); + } + } + }, + cmd::SubCommand::Worktree(args) => { + let cwd = std::env::current_dir().unwrap_or_else(|error| { + print_error(&format!("Could not open current directory: {}", error)); + process::exit(1); + }); + + match args.action { + cmd::WorktreeAction::Add(action_args) => { + let track = match &action_args.track { + Some(branch) => { + let split = branch.split_once('/'); + + if split.is_none() || + split.unwrap().0.len() == 0 + ||split.unwrap().1.len() == 0 { + print_error("Tracking branch needs to match the pattern /"); + process::exit(1); + }; + + // unwrap() here is safe because we checked for + // is_none() explictily before + let (remote_name, remote_branch_name) = split.unwrap(); + + Some((remote_name, remote_branch_name)) + } + None => None, + }; + + match grm::add_worktree( + &cwd, + &action_args.name, + action_args.branch_namespace.as_deref(), + track, + ) { + Ok(_) => print_success(&format!("Worktree {} created", &action_args.name)), + Err(error) => { + print_error(&format!("Error creating worktree: {}", error)); + process::exit(1); + } + } + } + cmd::WorktreeAction::Delete(action_args) => { + let worktree_dir = cwd.join(&action_args.name); + let repo = grm::Repo::open(&cwd, true).unwrap_or_else(|error| { + print_error(&format!("Error opening repository: {}", error)); + process::exit(1); + }); + + match repo.remove_worktree(&action_args.name, &worktree_dir, action_args.force) + { + Ok(_) => print_success(&format!("Worktree {} deleted", &action_args.name)), + Err(error) => { + match error { + grm::WorktreeRemoveFailureReason::Error(msg) => { + print_error(&msg); + process::exit(1); + } + grm::WorktreeRemoveFailureReason::Changes(changes) => { + print_warning(&format!( + "Changes in worktree: {}. Refusing to delete", + changes + )); + } + } + process::exit(1); + } + } + } + cmd::WorktreeAction::Status(_args) => { + let repo = grm::Repo::open(&cwd, true).unwrap_or_else(|error| { + print_error(&format!("Error opening repository: {}", error)); + process::exit(1); + }); + + match grm::table::get_worktree_status_table(&repo, &cwd) { + Ok((table, errors)) => { + println!("{}", table); + for error in errors { + print_error(&format!("Error: {}", error)); + } + } + Err(error) => print_error(&format!("Error getting status: {}", error)), + } + } + cmd::WorktreeAction::Convert(_args) => { + // Converting works like this: + // * Check whether there are uncommitted/unpushed changes + // * Move the contents of .git dir to the worktree directory + // * Remove all files + // * Set `core.bare` to `true` + + let repo = grm::Repo::open(&cwd, false).unwrap_or_else(|error| { + if error.kind == grm::RepoErrorKind::NotFound { + print_error("Directory does not contain a git repository"); + } else { + print_error(&format!("Opening repository failed: {}", error)); + } + process::exit(1); + }); + + let status = repo.status(false).unwrap_or_else(|error| { + print_error(&format!("Failed getting repo changes: {}", error)); + process::exit(1); + }); + if status.changes.is_some() { + print_error("Changes found in repository, refusing to convert"); + process::exit(1); + } + + match repo.convert_to_worktree(&cwd) { + Ok(_) => print_success("Conversion done"), + Err(error) => print_error(&format!("Error during conversion: {}", error)), + } + } + cmd::WorktreeAction::Clean(_args) => { + let repo = grm::Repo::open(&cwd, true).unwrap_or_else(|error| { + if error.kind == grm::RepoErrorKind::NotFound { + print_error("Directory does not contain a git repository"); + } else { + print_error(&format!("Opening repository failed: {}", error)); + } + process::exit(1); + }); + + match repo.cleanup_worktrees(&cwd) { + Ok(warnings) => { + for warning in warnings { + print_warning(&warning); + } + }, + Err(error) => { + print_error(&format!("Worktree cleanup failed: {}", error)); + process::exit(1); + } + } + + for unmanaged_worktree in + repo.find_unmanaged_worktrees(&cwd).unwrap_or_else(|error| { + print_error(&format!("Failed finding unmanaged worktrees: {}", error)); + process::exit(1); + }) + { + print_warning(&format!( + "Found {}, which is not a valid worktree directory!", + &unmanaged_worktree + )); + } + } + } + } + } +} diff --git a/src/lib.rs b/src/lib.rs index dd32fa6..4b78967 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -4,20 +4,17 @@ use std::fs; use std::path::{Path, PathBuf}; use std::process; -mod cmd; -mod config; -mod output; +pub mod config; +pub mod output; pub mod repo; +pub mod table; use config::{Config, Tree}; use output::*; -use comfy_table::{Cell, Table}; +use repo::{clone_repo, detect_remote_type, Remote, RepoConfig}; -use repo::{ - clone_repo, detect_remote_type, get_repo_status, init_repo, open_repo, repo_make_bare, - repo_set_config_push, Remote, RemoteTrackingStatus, Repo, RepoErrorKind, -}; +pub use repo::{RemoteTrackingStatus, RepoErrorKind, Repo, WorktreeRemoveFailureReason}; const GIT_MAIN_WORKTREE_DIRECTORY: &str = ".git-main-working-tree"; const BRANCH_NAMESPACE_SEPARATOR: &str = "/"; @@ -65,7 +62,7 @@ mod tests { } } -fn path_as_string(path: &Path) -> String { +pub fn path_as_string(path: &Path) -> String { path.to_path_buf().into_os_string().into_string().unwrap() } @@ -105,208 +102,175 @@ fn expand_path(path: &Path) -> PathBuf { Path::new(&expanded_path).to_path_buf() } -fn get_default_branch(repo: &git2::Repository) -> Result { - match repo.find_branch("main", git2::BranchType::Local) { - Ok(branch) => Ok(branch), - Err(_) => match repo.find_branch("master", git2::BranchType::Local) { - Ok(branch) => Ok(branch), - Err(_) => Err(String::from("Could not determine default branch")), - }, +fn sync_repo(root_path: &Path, repo: &RepoConfig) -> Result<(), String> { + let repo_path = root_path.join(&repo.name); + let actual_git_directory = get_actual_git_directory(&repo_path, repo.worktree_setup); + + let mut repo_handle = None; + + if repo_path.exists() { + if repo.worktree_setup && !actual_git_directory.exists() { + return Err(String::from( + "Repo already exists, but is not using a worktree setup", + )); + } + repo_handle = match Repo::open(&repo_path, repo.worktree_setup) { + Ok(repo) => Some(repo), + Err(error) => { + if !repo.worktree_setup && Repo::open(&repo_path, true).is_ok() { + return Err(String::from( + "Repo already exists, but is using a worktree setup", + )); + } else { + return Err(format!("Opening repository failed: {}", error)); + } + } + }; + } else if matches!(&repo.remotes, None) || repo.remotes.as_ref().unwrap().is_empty() { + print_repo_action( + &repo.name, + "Repository does not have remotes configured, initializing new", + ); + repo_handle = match Repo::init(&repo_path, repo.worktree_setup) { + Ok(r) => { + print_repo_success(&repo.name, "Repository created"); + Some(r) + } + Err(e) => { + return Err(format!("Repository failed during init: {}", e)); + } + } + } else { + let first = repo.remotes.as_ref().unwrap().first().unwrap(); + + match clone_repo(first, &repo_path, repo.worktree_setup) { + Ok(_) => { + print_repo_success(&repo.name, "Repository successfully cloned"); + } + Err(e) => { + return Err(format!("Repository failed during clone: {}", e)); + } + }; } + if let Some(remotes) = &repo.remotes { + let repo_handle = repo_handle.unwrap_or_else(|| { + Repo::open(&repo_path, repo.worktree_setup).unwrap_or_else(|_| process::exit(1)) + }); + + let current_remotes: Vec = repo_handle + .remotes() + .map_err(|error| format!("Repository failed during getting the remotes: {}", error))?; + + for remote in remotes { + let current_remote = repo_handle.find_remote(&remote.name)?; + + match current_remote { + Some(current_remote) => { + let current_url = current_remote.url(); + + if remote.url != current_url { + print_repo_action( + &repo.name, + &format!("Updating remote {} to \"{}\"", &remote.name, &remote.url), + ); + if let Err(e) = repo_handle.remote_set_url(&remote.name, &remote.url) { + return Err(format!("Repository failed during setting of the remote URL for remote \"{}\": {}", &remote.name, e)); + }; + } + }, + None => { + print_repo_action( + &repo.name, + &format!( + "Setting up new remote \"{}\" to \"{}\"", + &remote.name, &remote.url + ), + ); + if let Err(e) = repo_handle.new_remote(&remote.name, &remote.url) { + return Err(format!( + "Repository failed during setting the remotes: {}", + e + )); + } + } + } + } + + for current_remote in ¤t_remotes { + if !remotes.iter().any(|r| &r.name == current_remote) { + print_repo_action( + &repo.name, + &format!("Deleting remote \"{}\"", ¤t_remote,), + ); + if let Err(e) = repo_handle.remote_delete(current_remote) { + return Err(format!( + "Repository failed during deleting remote \"{}\": {}", + ¤t_remote, e + )); + } + } + } + } + Ok(()) } -fn sync_trees(config: Config) -> bool { +pub fn find_unmanaged_repos( + root_path: &Path, + managed_repos: &[RepoConfig], +) -> Result, String> { + let mut unmanaged_repos = Vec::new(); + + for repo in find_repo_paths(root_path)? { + let name = path_as_string(repo.strip_prefix(&root_path).unwrap()); + if !managed_repos.iter().any(|r| r.name == name) { + unmanaged_repos.push(name); + } + } + Ok(unmanaged_repos) +} + +pub fn sync_trees(config: Config) -> Result { let mut failures = false; - for tree in config.trees { + for tree in config.trees.as_vec() { let repos = tree.repos.unwrap_or_default(); let root_path = expand_path(Path::new(&tree.root)); for repo in &repos { - let repo_path = root_path.join(&repo.name); - let actual_git_directory = get_actual_git_directory(&repo_path, repo.worktree_setup); - - let mut repo_handle = None; - - if repo_path.exists() { - if repo.worktree_setup && !actual_git_directory.exists() { - print_repo_error( - &repo.name, - "Repo already exists, but is not using a worktree setup", - ); + match sync_repo(&root_path, repo) { + Ok(_) => print_repo_success(&repo.name, "OK"), + Err(error) => { + print_repo_error(&repo.name, &error); failures = true; - continue; - } - repo_handle = match open_repo(&repo_path, repo.worktree_setup) { - Ok(repo) => Some(repo), - Err(error) => { - if !repo.worktree_setup { - if open_repo(&repo_path, true).is_ok() { - print_repo_error( - &repo.name, - "Repo already exists, but is using a worktree setup", - ); - } - } else { - print_repo_error( - &repo.name, - &format!("Opening repository failed: {}", error), - ); - } - failures = true; - continue; - } - }; - } else if matches!(&repo.remotes, None) || repo.remotes.as_ref().unwrap().is_empty() { - print_repo_action( - &repo.name, - "Repository does not have remotes configured, initializing new", - ); - repo_handle = match init_repo(&repo_path, repo.worktree_setup) { - Ok(r) => { - print_repo_success(&repo.name, "Repository created"); - Some(r) - } - Err(e) => { - print_repo_error( - &repo.name, - &format!("Repository failed during init: {}", e), - ); - None - } - } - } else { - let first = repo.remotes.as_ref().unwrap().first().unwrap(); - - match clone_repo(first, &repo_path, repo.worktree_setup) { - Ok(_) => { - print_repo_success(&repo.name, "Repository successfully cloned"); - } - Err(e) => { - print_repo_error( - &repo.name, - &format!("Repository failed during clone: {}", e), - ); - continue; - } - }; - } - if let Some(remotes) = &repo.remotes { - let repo_handle = repo_handle.unwrap_or_else(|| { - open_repo(&repo_path, repo.worktree_setup).unwrap_or_else(|_| process::exit(1)) - }); - - let current_remotes: Vec = match repo_handle.remotes() { - Ok(r) => r, - Err(e) => { - print_repo_error( - &repo.name, - &format!("Repository failed during getting the remotes: {}", e), - ); - failures = true; - continue; - } - } - .iter() - .flatten() - .map(|r| r.to_owned()) - .collect(); - - for remote in remotes { - if !current_remotes.iter().any(|r| *r == remote.name) { - print_repo_action( - &repo.name, - &format!( - "Setting up new remote \"{}\" to \"{}\"", - &remote.name, &remote.url - ), - ); - if let Err(e) = repo_handle.remote(&remote.name, &remote.url) { - print_repo_error( - &repo.name, - &format!("Repository failed during setting the remotes: {}", e), - ); - failures = true; - continue; - } - } else { - let current_remote = repo_handle.find_remote(&remote.name).unwrap(); - let current_url = match current_remote.url() { - Some(url) => url, - None => { - print_repo_error(&repo.name, &format!("Repository failed during getting of the remote URL for remote \"{}\". This is most likely caused by a non-utf8 remote name", remote.name)); - failures = true; - continue; - } - }; - if remote.url != current_url { - print_repo_action( - &repo.name, - &format!("Updating remote {} to \"{}\"", &remote.name, &remote.url), - ); - if let Err(e) = repo_handle.remote_set_url(&remote.name, &remote.url) { - print_repo_error(&repo.name, &format!("Repository failed during setting of the remote URL for remote \"{}\": {}", &remote.name, e)); - failures = true; - continue; - }; - } - } - } - - for current_remote in ¤t_remotes { - if !remotes.iter().any(|r| &r.name == current_remote) { - print_repo_action( - &repo.name, - &format!("Deleting remote \"{}\"", ¤t_remote,), - ); - if let Err(e) = repo_handle.remote_delete(current_remote) { - print_repo_error( - &repo.name, - &format!( - "Repository failed during deleting remote \"{}\": {}", - ¤t_remote, e - ), - ); - failures = true; - continue; - } - } } } - - print_repo_success(&repo.name, "OK"); } - let current_repos = match find_repos_without_details(&root_path) { - Ok(repos) => repos, - Err(error) => { - print_error(&error.to_string()); - failures = true; - continue; + match find_unmanaged_repos(&root_path, &repos) { + Ok(unmanaged_repos) => { + for name in unmanaged_repos { + print_warning(&format!("Found unmanaged repository: {}", name)); + } } - }; - - for (repo, _) in current_repos { - let name = path_as_string(repo.strip_prefix(&root_path).unwrap()); - if !repos.iter().any(|r| r.name == name) { - print_warning(&format!("Found unmanaged repository: {}", name)); + Err(error) => { + print_error(&format!("Error getting unmanaged repos: {}", error)); + failures = true; } } } - !failures + Ok(!failures) } -fn find_repos_without_details(path: &Path) -> Result, String> { - let mut repos: Vec<(PathBuf, bool)> = Vec::new(); +/// Finds repositories recursively, returning their path +fn find_repo_paths(path: &Path) -> Result, String> { + let mut repos = Vec::new(); let git_dir = path.join(".git"); let git_worktree = path.join(GIT_MAIN_WORKTREE_DIRECTORY); - if git_dir.exists() { - repos.push((path.to_path_buf(), false)); - } else if git_worktree.exists() { - repos.push((path.to_path_buf(), true)); + if git_dir.exists() || git_worktree.exists() { + repos.push(path.to_path_buf()); } else { match fs::read_dir(path) { Ok(contents) => { @@ -318,7 +282,7 @@ fn find_repos_without_details(path: &Path) -> Result, Strin continue; } if path.is_dir() { - match find_repos_without_details(&path) { + match find_repo_paths(&path) { Ok(ref mut r) => repos.append(r), Err(error) => return Err(error), } @@ -355,97 +319,64 @@ fn get_actual_git_directory(path: &Path, is_worktree: bool) -> PathBuf { } } -fn find_repos(root: &Path) -> Option<(Vec, bool)> { - let mut repos: Vec = Vec::new(); +/// Find all git repositories under root, recursively +/// +/// The bool in the return value specifies whether there is a repository +/// in root itself. +fn find_repos(root: &Path) -> Result, bool)>, String> { + let mut repos: Vec = Vec::new(); let mut repo_in_root = false; - for (path, is_worktree) in find_repos_without_details(root).unwrap() { + for path in find_repo_paths(root)? { + let is_worktree = Repo::detect_worktree(&path); if path == root { repo_in_root = true; } - match open_repo(&path, is_worktree) { - Err(e) => { - print_error(&format!( + + match Repo::open(&path, is_worktree) { + Err(error) => { + return Err(format!( "Error opening repo {}{}: {}", path.display(), match is_worktree { true => " as worktree", false => "", }, - e - )); - } + error + )) + }, Ok(repo) => { - let remotes = match repo.remotes() { - Ok(remotes) => { - let mut results: Vec = Vec::new(); - for remote in remotes.iter() { - match remote { - Some(remote_name) => { - match repo.find_remote(remote_name) { - Ok(remote) => { - let name = match remote.name() { - Some(name) => name.to_string(), - None => { - print_repo_error(&path_as_string(&path), &format!("Falied getting name of remote \"{}\". This is most likely caused by a non-utf8 remote name", remote_name)); - process::exit(1); - } - }; - let url = match remote.url() { - Some(url) => url.to_string(), - None => { - print_repo_error(&path_as_string(&path), &format!("Falied getting URL of remote \"{}\". This is most likely caused by a non-utf8 URL", name)); - process::exit(1); - } - }; - let remote_type = match detect_remote_type(&url) { - Some(t) => t, - None => { - print_repo_error( - &path_as_string(&path), - &format!( - "Could not detect remote type of \"{}\"", - &url - ), - ); - process::exit(1); - } - }; - results.push(Remote { - name, - url, - remote_type, - }); - } - Err(e) => { - print_repo_error( - &path_as_string(&path), - &format!( - "Error getting remote {}: {}", - remote_name, e - ), - ); - process::exit(1); - } - }; - } + let remotes = repo.remotes().map_err(|error| { + format!("{}: Error getting remotes: {}", &path_as_string(&path), error) + })?; + + let mut results: Vec = Vec::new(); + for remote_name in remotes.iter() { + match repo.find_remote(remote_name)? { + Some(remote) => { + let name = remote.name(); + let url = remote.url(); + let remote_type = match detect_remote_type(&url) { + Some(t) => t, None => { - print_repo_error(&path_as_string(&path), "Error getting remote. This is most likely caused by a non-utf8 remote name"); - process::exit(1); + return Err(format!("{}: Could not detect remote type of \"{}\"", &path_as_string(&path), &url)); } }; + + results.push(Remote { + name, + url, + remote_type, + }); } - Some(results) - } - Err(e) => { - print_repo_error( - &path_as_string(&path), - &format!("Error getting remotes: {}", e), - ); - process::exit(1); - } - }; - repos.push(Repo { + None => { + return Err(format!("{}: Remote {} not found", &path_as_string(&path), remote_name)); + } + }; + } + let remotes = results; + + repos.push(RepoConfig { name: match path == root { true => match &root.parent() { Some(parent) => path_as_string(path.strip_prefix(parent).unwrap()), @@ -456,17 +387,18 @@ fn find_repos(root: &Path) -> Option<(Vec, bool)> { } false => path_as_string(path.strip_prefix(&root).unwrap()), }, - remotes, + remotes: Some(remotes), worktree_setup: is_worktree, }); } - }; + } + } - Some((repos, repo_in_root)) + Ok(Some((repos, repo_in_root))) } -fn find_in_tree(path: &Path) -> Option { - let (repos, repo_in_root): (Vec, bool) = match find_repos(path) { +pub fn find_in_tree(path: &Path) -> Result { + let (repos, repo_in_root): (Vec, bool) = match find_repos(path)? { Some((vec, repo_in_root)) => (vec, repo_in_root), None => (Vec::new(), false), }; @@ -476,8 +408,9 @@ fn find_in_tree(path: &Path) -> Option { root = match root.parent() { Some(root) => root.to_path_buf(), None => { - print_error("Cannot detect root directory. Are you working in /?"); - process::exit(1); + return Err(String::from( + "Cannot detect root directory. Are you working in /?", + )); } } } @@ -485,801 +418,80 @@ fn find_in_tree(path: &Path) -> Option { if root.starts_with(&home) { // The tilde is not handled differently, it's just a normal path component for `Path`. // Therefore we can treat it like that during **output**. + // + // The `unwrap()` is safe here as we are testing via `starts_with()` + // beforehand root = Path::new("~").join(root.strip_prefix(&home).unwrap()); } - Some(Tree { + Ok(Tree { root: root.into_os_string().into_string().unwrap(), repos: Some(repos), }) } -fn add_table_header(table: &mut Table) { - table - .load_preset(comfy_table::presets::UTF8_FULL) - .apply_modifier(comfy_table::modifiers::UTF8_ROUND_CORNERS) - .set_header(vec![ - Cell::new("Repo"), - Cell::new("Worktree"), - Cell::new("Status"), - Cell::new("Branches"), - Cell::new("HEAD"), - Cell::new("Remotes"), - ]); -} - -fn add_worktree_table_header(table: &mut Table) { - table - .load_preset(comfy_table::presets::UTF8_FULL) - .apply_modifier(comfy_table::modifiers::UTF8_ROUND_CORNERS) - .set_header(vec![ - Cell::new("Worktree"), - Cell::new("Status"), - Cell::new("Branch"), - Cell::new("Remote branch"), - ]); -} - -fn add_repo_status( - table: &mut Table, - repo_name: &str, - repo_handle: &git2::Repository, - is_worktree: bool, -) { - let repo_status = get_repo_status(repo_handle, is_worktree); - - table.add_row(vec![ - repo_name, - match is_worktree { - true => "\u{2714}", - false => "", - }, - &match repo_status.changes { - None => String::from("-"), - Some(changes) => match changes { - Some(changes) => { - let mut out = Vec::new(); - if changes.files_new > 0 { - out.push(format!("New: {}\n", changes.files_new)) - } - if changes.files_modified > 0 { - out.push(format!("Modified: {}\n", changes.files_modified)) - } - if changes.files_deleted > 0 { - out.push(format!("Deleted: {}\n", changes.files_deleted)) - } - out.into_iter().collect::().trim().to_string() - } - None => String::from("\u{2714}"), - }, - }, - &repo_status - .branches - .iter() - .map(|(branch_name, remote_branch)| { - format!( - "branch: {}{}\n", - &branch_name, - &match remote_branch { - None => String::from(" "), - Some((remote_branch_name, remote_tracking_status)) => { - format!( - " <{}>{}", - remote_branch_name, - &match remote_tracking_status { - RemoteTrackingStatus::UpToDate => String::from(" \u{2714}"), - RemoteTrackingStatus::Ahead(d) => format!(" [+{}]", &d), - RemoteTrackingStatus::Behind(d) => format!(" [-{}]", &d), - RemoteTrackingStatus::Diverged(d1, d2) => - format!(" [+{}/-{}]", &d1, &d2), - } - ) - } - } - ) - }) - .collect::() - .trim() - .to_string(), - &match is_worktree { - true => String::from(""), - false => match repo_status.head { - Some(head) => head, - None => String::from("Empty"), - }, - }, - &repo_status - .remotes - .iter() - .map(|r| format!("{}\n", r)) - .collect::() - .trim() - .to_string(), - ]); -} - -fn add_worktree_status(table: &mut Table, worktree_name: &str, repo: &git2::Repository) { - let repo_status = get_repo_status(repo, false); - - let head = repo.head().unwrap(); - - if !head.is_branch() { - print_error("No branch checked out in worktree"); - process::exit(1); - } - - let local_branch_name = head.shorthand().unwrap(); - let local_branch = repo - .find_branch(local_branch_name, git2::BranchType::Local) - .unwrap(); - - let upstream_output = match local_branch.upstream() { - Ok(remote_branch) => { - let remote_branch_name = remote_branch.name().unwrap().unwrap().to_string(); - - let (ahead, behind) = repo - .graph_ahead_behind( - local_branch.get().peel_to_commit().unwrap().id(), - remote_branch.get().peel_to_commit().unwrap().id(), - ) - .unwrap(); - - format!( - "{}{}\n", - &remote_branch_name, - &match (ahead, behind) { - (0, 0) => String::from(""), - (d, 0) => format!(" [+{}]", &d), - (0, d) => format!(" [-{}]", &d), - (d1, d2) => format!(" [+{}/-{}]", &d1, &d2), - }, - ) - } - Err(_) => String::from(""), - }; - - table.add_row(vec![ - worktree_name, - &match repo_status.changes { - None => String::from(""), - Some(changes) => match changes { - Some(changes) => { - let mut out = Vec::new(); - if changes.files_new > 0 { - out.push(format!("New: {}\n", changes.files_new)) - } - if changes.files_modified > 0 { - out.push(format!("Modified: {}\n", changes.files_modified)) - } - if changes.files_deleted > 0 { - out.push(format!("Deleted: {}\n", changes.files_deleted)) - } - out.into_iter().collect::().trim().to_string() - } - None => String::from("\u{2714}"), - }, - }, - local_branch_name, - &upstream_output, - ]); -} - -fn show_single_repo_status(path: &Path, is_worktree: bool) { - let mut table = Table::new(); - add_table_header(&mut table); - - let repo_handle = open_repo(path, is_worktree); - - if let Err(error) = repo_handle { - if error.kind == RepoErrorKind::NotFound { - print_error("Directory is not a git directory"); - } else { - print_error(&format!("Opening repository failed: {}", error)); - } - process::exit(1); - }; - - let repo_name = match path.file_name() { - None => { - print_warning("Cannot detect repo name. Are you working in /?"); - String::from("unknown") - } - Some(file_name) => match file_name.to_str() { - None => { - print_warning("Name of current directory is not valid UTF-8"); - String::from("invalid") - } - Some(name) => name.to_string(), - }, - }; - - add_repo_status(&mut table, &repo_name, &repo_handle.unwrap(), is_worktree); - - println!("{}", table); -} - -fn show_status(config: Config) { - for tree in config.trees { - let repos = tree.repos.unwrap_or_default(); - - let root_path = expand_path(Path::new(&tree.root)); - - let mut table = Table::new(); - add_table_header(&mut table); - - for repo in &repos { - let repo_path = root_path.join(&repo.name); - - if !repo_path.exists() { - print_repo_error( - &repo.name, - &"Repository does not exist. Run sync?".to_string(), - ); - continue; - } - - let repo_handle = open_repo(&repo_path, repo.worktree_setup); - - if let Err(error) = repo_handle { - if error.kind == RepoErrorKind::NotFound { - print_repo_error( - &repo.name, - &"No git repository found. Run sync?".to_string(), - ); - } else { - print_repo_error(&repo.name, &format!("Opening repository failed: {}", error)); - } - continue; - }; - - let repo_handle = repo_handle.unwrap(); - - add_repo_status(&mut table, &repo.name, &repo_handle, repo.worktree_setup); - } - println!("{}", table); - } -} - -enum WorktreeRemoveFailureReason { - Changes(String), - Error(String), -} - -fn remove_worktree( +pub fn add_worktree( + directory: &Path, name: &str, - worktree_dir: &Path, - force: bool, - main_repo: &git2::Repository, -) -> Result<(), WorktreeRemoveFailureReason> { - if !worktree_dir.exists() { - return Err(WorktreeRemoveFailureReason::Error(format!( - "{} does not exist", - name - ))); + branch_namespace: Option<&str>, + track: Option<(&str, &str)>, +) -> Result<(), String> { + let repo = Repo::open(directory, true).map_err(|error| match error.kind { + RepoErrorKind::NotFound => { + String::from("Current directory does not contain a worktree setup") + } + _ => format!("Error opening repo: {}", error), + })?; + + if repo.find_worktree(name).is_ok() { + return Err(format!("Worktree {} already exists", &name)); } - let worktree_repo = match open_repo(worktree_dir, false) { - Ok(r) => r, - Err(e) => { - return Err(WorktreeRemoveFailureReason::Error(format!( - "Error opening repo: {}", - e - ))); + + let branch_name = match branch_namespace { + Some(prefix) => format!("{}{}{}", &prefix, BRANCH_NAMESPACE_SEPARATOR, &name), + None => name.to_string(), + }; + + let mut remote_branch_exists = false; + + let checkout_commit = match track { + Some((remote_name, remote_branch_name)) => { + let remote_branch = repo.find_remote_branch(remote_name, remote_branch_name); + match remote_branch { + Ok(branch) => { + remote_branch_exists = true; + branch.to_commit()? + } + Err(_) => { + remote_branch_exists = false; + repo.default_branch()?.to_commit()? + } + } + } + None => repo.default_branch()?.to_commit()?, + }; + + let mut target_branch = match repo.find_local_branch(&branch_name) { + Ok(branchref) => branchref, + Err(_) => repo.create_branch(&branch_name, &checkout_commit)?, + }; + + if let Some((remote_name, remote_branch_name)) = track { + if remote_branch_exists { + target_branch.set_upstream(remote_name, remote_branch_name)?; + } else { + let mut remote = repo + .find_remote(remote_name) + .map_err(|error| format!("Error getting remote {}: {}", remote_name, error))? + .ok_or_else(|| format!("Remote {} not found", remote_name))?; + + remote.push(&target_branch.name()?, remote_branch_name, &repo)?; + + target_branch.set_upstream(remote_name, remote_branch_name)?; } }; - let head = worktree_repo.head().unwrap(); - if !head.is_branch() { - return Err(WorktreeRemoveFailureReason::Error(String::from( - "No branch checked out in worktree", - ))); - } - - let branch_name = head.shorthand().unwrap(); - if branch_name != name - && !branch_name.ends_with(&format!("{}{}", BRANCH_NAMESPACE_SEPARATOR, name)) - { - return Err(WorktreeRemoveFailureReason::Error(format!( - "Branch {} is checked out in worktree, this does not look correct", - &branch_name - ))); - } - - let mut branch = worktree_repo - .find_branch(branch_name, git2::BranchType::Local) - .unwrap(); - - if !force { - let status = get_repo_status(&worktree_repo, false); - if status.changes.unwrap().is_some() { - return Err(WorktreeRemoveFailureReason::Changes(String::from( - "Changes found in worktree", - ))); - } - - match branch.upstream() { - Ok(remote_branch) => { - let (ahead, behind) = worktree_repo - .graph_ahead_behind( - branch.get().peel_to_commit().unwrap().id(), - remote_branch.get().peel_to_commit().unwrap().id(), - ) - .unwrap(); - - if (ahead, behind) != (0, 0) { - return Err(WorktreeRemoveFailureReason::Changes(format!( - "Branch {} is not in line with remote branch", - name - ))); - } - } - Err(_) => { - return Err(WorktreeRemoveFailureReason::Changes(format!( - "No remote tracking branch for branch {} found", - name - ))); - } - } - } - - if let Err(e) = std::fs::remove_dir_all(&worktree_dir) { - return Err(WorktreeRemoveFailureReason::Error(format!( - "Error deleting {}: {}", - &worktree_dir.display(), - e - ))); - } - main_repo.find_worktree(name).unwrap().prune(None).unwrap(); - branch.delete().unwrap(); + repo.new_worktree(name, &directory.join(&name), &target_branch)?; Ok(()) } - -pub fn run() { - let opts = cmd::parse(); - - match opts.subcmd { - cmd::SubCommand::Repos(repos) => match repos.action { - cmd::ReposAction::Sync(sync) => { - let config = match config::read_config(&sync.config) { - Ok(c) => c, - Err(e) => { - print_error(&e); - process::exit(1); - } - }; - if !sync_trees(config) { - process::exit(1); - } - } - cmd::ReposAction::Status(args) => match &args.config { - Some(config_path) => { - let config = match config::read_config(config_path) { - Ok(c) => c, - Err(e) => { - print_error(&e); - process::exit(1); - } - }; - show_status(config); - } - None => { - let dir = match std::env::current_dir() { - Ok(d) => d, - Err(e) => { - print_error(&format!("Could not open current directory: {}", e)); - process::exit(1); - } - }; - - let has_worktree = dir.join(GIT_MAIN_WORKTREE_DIRECTORY).exists(); - show_single_repo_status(&dir, has_worktree); - } - }, - cmd::ReposAction::Find(find) => { - let path = Path::new(&find.path); - if !path.exists() { - print_error(&format!("Path \"{}\" does not exist", path.display())); - process::exit(1); - } - let path = &path.canonicalize().unwrap(); - if !path.is_dir() { - print_error(&format!("Path \"{}\" is not a directory", path.display())); - process::exit(1); - } - - let trees = vec![find_in_tree(path).unwrap()]; - if trees.iter().all(|t| match &t.repos { - None => false, - Some(r) => r.is_empty(), - }) { - print_warning("No repositories found"); - } else { - let config = Config { trees }; - - let toml = toml::to_string(&config).unwrap(); - - print!("{}", toml); - } - } - }, - cmd::SubCommand::Worktree(args) => { - let dir = match std::env::current_dir() { - Ok(d) => d, - Err(e) => { - print_error(&format!("Could not open current directory: {}", e)); - process::exit(1); - } - }; - - fn get_repo(dir: &Path) -> git2::Repository { - match open_repo(dir, true) { - Ok(r) => r, - Err(e) => { - match e.kind { - RepoErrorKind::NotFound => { - print_error("Current directory does not contain a worktree setup") - } - _ => print_error(&format!("Error opening repo: {}", e)), - } - process::exit(1); - } - } - } - - fn get_worktrees(repo: &git2::Repository) -> Vec { - repo.worktrees() - .unwrap() - .iter() - .map(|e| e.unwrap().to_string()) - .collect::>() - } - - match args.action { - cmd::WorktreeAction::Add(action_args) => { - let repo = get_repo(&dir); - let worktrees = get_worktrees(&repo); - if worktrees.contains(&action_args.name) { - print_error("Worktree already exists"); - process::exit(1); - } - - let branch_name = match action_args.branch_namespace { - Some(prefix) => format!( - "{}{}{}", - &prefix, BRANCH_NAMESPACE_SEPARATOR, &action_args.name - ), - None => action_args.name.clone(), - }; - - let mut remote_branch_exists = false; - - let checkout_commit = match &action_args.track { - Some(upstream_branch_name) => { - match repo.find_branch(upstream_branch_name, git2::BranchType::Remote) { - Ok(branch) => { - remote_branch_exists = true; - branch.into_reference().peel_to_commit().unwrap() - } - Err(_) => { - remote_branch_exists = false; - get_default_branch(&repo) - .unwrap() - .into_reference() - .peel_to_commit() - .unwrap() - } - } - } - None => get_default_branch(&repo) - .unwrap() - .into_reference() - .peel_to_commit() - .unwrap(), - }; - - let mut target_branch = - match repo.find_branch(&branch_name, git2::BranchType::Local) { - Ok(branchref) => branchref, - Err(_) => repo.branch(&branch_name, &checkout_commit, false).unwrap(), - }; - - if let Some(upstream_branch_name) = action_args.track { - if remote_branch_exists { - target_branch - .set_upstream(Some(&upstream_branch_name)) - .unwrap(); - } else { - let split_at = upstream_branch_name.find('/').unwrap_or(0); - if split_at == 0 || split_at >= upstream_branch_name.len() - 1 { - print_error("Tracking branch needs to match the pattern /"); - process::exit(1); - } - - let (remote_name, remote_branch_name) = - &upstream_branch_name.split_at(split_at); - // strip the remaining slash - let remote_branch_name = &remote_branch_name[1..]; - - let mut remote = match repo.find_remote(remote_name) { - Ok(r) => r, - Err(_) => { - print_error(&format!("Remote {} not found", remote_name)); - process::exit(1); - } - }; - - let mut callbacks = git2::RemoteCallbacks::new(); - callbacks.push_update_reference(|_, status| { - if let Some(message) = status { - return Err(git2::Error::new( - git2::ErrorCode::GenericError, - git2::ErrorClass::None, - message, - )); - } - Ok(()) - }); - callbacks.credentials(|_url, username_from_url, _allowed_types| { - git2::Cred::ssh_key_from_agent(username_from_url.unwrap()) - }); - - let mut push_options = git2::PushOptions::new(); - push_options.remote_callbacks(callbacks); - - let push_refspec = format!( - "+{}:refs/heads/{}", - target_branch.get().name().unwrap(), - remote_branch_name - ); - remote - .push(&[push_refspec], Some(&mut push_options)) - .unwrap_or_else(|error| { - print_error(&format!( - "Pushing to {} ({}) failed: {}", - remote_name, - remote.url().unwrap(), - error - )); - process::exit(1); - }); - - target_branch - .set_upstream(Some(&upstream_branch_name)) - .unwrap(); - } - }; - - let worktree = repo.worktree( - &action_args.name, - &dir.join(&action_args.name), - Some(git2::WorktreeAddOptions::new().reference(Some(target_branch.get()))), - ); - - match worktree { - Ok(_) => print_success(&format!("Worktree {} created", &action_args.name)), - Err(e) => { - print_error(&format!("Error creating worktree: {}", e)); - process::exit(1); - } - }; - } - - cmd::WorktreeAction::Delete(action_args) => { - let worktree_dir = dir.join(&action_args.name); - let repo = get_repo(&dir); - - match remove_worktree( - &action_args.name, - &worktree_dir, - action_args.force, - &repo, - ) { - Ok(_) => print_success(&format!("Worktree {} deleted", &action_args.name)), - Err(error) => { - match error { - WorktreeRemoveFailureReason::Error(msg) => { - print_error(&msg); - process::exit(1); - } - WorktreeRemoveFailureReason::Changes(changes) => { - print_warning(&format!( - "Changes in worktree: {}. Refusing to delete", - changes - )); - } - } - process::exit(1); - } - } - } - cmd::WorktreeAction::Status(_args) => { - let repo = get_repo(&dir); - let worktrees = get_worktrees(&repo); - let mut table = Table::new(); - add_worktree_table_header(&mut table); - for worktree in &worktrees { - let repo_dir = &dir.join(&worktree); - if repo_dir.exists() { - let repo = match open_repo(repo_dir, false) { - Ok(r) => r, - Err(e) => { - print_error(&format!("Error opening repo: {}", e)); - process::exit(1); - } - }; - add_worktree_status(&mut table, worktree, &repo); - } else { - print_warning(&format!( - "Worktree {} does not have a directory", - &worktree - )); - } - } - for entry in std::fs::read_dir(&dir).unwrap() { - let dirname = path_as_string( - &entry - .unwrap() - .path() - .strip_prefix(&dir) - .unwrap() - .to_path_buf(), - ); - if dirname == GIT_MAIN_WORKTREE_DIRECTORY { - continue; - } - if !&worktrees.contains(&dirname) { - print_warning(&format!( - "Found {}, which is not a valid worktree directory!", - &dirname - )); - } - } - println!("{}", table); - } - cmd::WorktreeAction::Convert(_args) => { - // Converting works like this: - // * Check whether there are uncommitted/unpushed changes - // * Move the contents of .git dir to the worktree directory - // * Remove all files - // * Set `core.bare` to `true` - - let repo = open_repo(&dir, false).unwrap_or_else(|error| { - if error.kind == RepoErrorKind::NotFound { - print_error("Directory does not contain a git repository"); - } else { - print_error(&format!("Opening repository failed: {}", error)); - } - process::exit(1); - }); - - let status = get_repo_status(&repo, false); - if status.changes.unwrap().is_some() { - print_error("Changes found in repository, refusing to convert"); - } - - if let Err(error) = std::fs::rename(".git", GIT_MAIN_WORKTREE_DIRECTORY) { - print_error(&format!("Error moving .git directory: {}", error)); - } - - for entry in match std::fs::read_dir(&dir) { - Ok(iterator) => iterator, - Err(error) => { - print_error(&format!("Opening directory failed: {}", error)); - process::exit(1); - } - } { - match entry { - Ok(entry) => { - let path = entry.path(); - // The path will ALWAYS have a file component - if path.file_name().unwrap() == GIT_MAIN_WORKTREE_DIRECTORY { - continue; - } - if path.is_file() || path.is_symlink() { - if let Err(error) = std::fs::remove_file(&path) { - print_error(&format!("Failed removing {}", error)); - process::exit(1); - } - } else if let Err(error) = std::fs::remove_dir_all(&path) { - print_error(&format!("Failed removing {}", error)); - process::exit(1); - } - } - Err(error) => { - print_error(&format!("Error getting directory entry: {}", error)); - process::exit(1); - } - } - } - - let worktree_repo = open_repo(&dir, true).unwrap_or_else(|error| { - print_error(&format!( - "Opening newly converted repository failed: {}", - error - )); - process::exit(1); - }); - - repo_make_bare(&worktree_repo, true).unwrap_or_else(|error| { - print_error(&format!("Error: {}", error)); - process::exit(1); - }); - - repo_set_config_push(&worktree_repo, "upstream").unwrap_or_else(|error| { - print_error(&format!("Error: {}", error)); - process::exit(1); - }); - - print_success("Conversion done"); - } - cmd::WorktreeAction::Clean(_args) => { - let repo = get_repo(&dir); - let worktrees = get_worktrees(&repo); - - let default_branch = match get_default_branch(&repo) { - Ok(branch) => branch, - Err(error) => { - print_error(&format!("Failed getting default branch: {}", error)); - process::exit(1); - } - }; - - let default_branch_name = default_branch.name().unwrap().unwrap(); - - for worktree in worktrees - .iter() - .filter(|worktree| *worktree != default_branch_name) - { - let repo_dir = &dir.join(&worktree); - if repo_dir.exists() { - match remove_worktree(worktree, repo_dir, false, &repo) { - Ok(_) => print_success(&format!("Worktree {} deleted", &worktree)), - Err(error) => match error { - WorktreeRemoveFailureReason::Changes(changes) => { - print_warning(&format!( - "Changes found in {}: {}, skipping", - &worktree, &changes - )); - continue; - } - WorktreeRemoveFailureReason::Error(e) => { - print_error(&e); - process::exit(1); - } - }, - } - } else { - print_warning(&format!( - "Worktree {} does not have a directory", - &worktree - )); - } - } - - for entry in std::fs::read_dir(&dir).unwrap() { - let dirname = path_as_string( - &entry - .unwrap() - .path() - .strip_prefix(&dir) - .unwrap() - .to_path_buf(), - ); - if dirname == GIT_MAIN_WORKTREE_DIRECTORY { - continue; - } - if dirname == default_branch_name { - continue; - } - if !&worktrees.contains(&dirname) { - print_warning(&format!( - "Found {}, which is not a valid worktree directory!", - &dirname - )); - } - } - } - } - } - } -} diff --git a/src/main.rs b/src/main.rs deleted file mode 100644 index 76fe0cb..0000000 --- a/src/main.rs +++ /dev/null @@ -1,5 +0,0 @@ -use grm::run; - -fn main() { - run(); -} diff --git a/src/repo.rs b/src/repo.rs index a917f4a..e127f7e 100644 --- a/src/repo.rs +++ b/src/repo.rs @@ -13,6 +13,15 @@ pub enum RemoteType { File, } +pub enum WorktreeRemoveFailureReason { + Changes(String), + Error(String), +} + +pub enum GitPushDefaultSetting { + Upstream +} + #[derive(Debug, PartialEq)] pub enum RepoErrorKind { NotFound, @@ -53,7 +62,7 @@ fn worktree_setup_default() -> bool { #[derive(Debug, Serialize, Deserialize)] #[serde(deny_unknown_fields)] -pub struct Repo { +pub struct RepoConfig { pub name: String, #[serde(default = "worktree_setup_default")] @@ -91,10 +100,7 @@ pub struct RepoStatus { pub head: Option, - // None(_) => Could not get changes (e.g. because it's a worktree setup) - // Some(None) => No changes - // Some(Some(_)) => Changes - pub changes: Option>, + pub changes: Option, pub worktrees: usize, @@ -181,64 +187,737 @@ pub fn detect_remote_type(remote_url: &str) -> Option { None } -pub fn open_repo(path: &Path, is_worktree: bool) -> Result { - let open_func = match is_worktree { - true => Repository::open_bare, - false => Repository::open, - }; - let path = match is_worktree { - true => path.join(super::GIT_MAIN_WORKTREE_DIRECTORY), - false => path.to_path_buf(), - }; - match open_func(path) { - Ok(r) => Ok(r), - Err(e) => match e.code() { - git2::ErrorCode::NotFound => Err(RepoError::new(RepoErrorKind::NotFound)), - _ => Err(RepoError::new(RepoErrorKind::Unknown( - e.message().to_string(), - ))), - }, - } +pub struct Repo(git2::Repository); +pub struct Branch<'a>(git2::Branch<'a>); + +fn convert_libgit2_error(error: git2::Error) -> String { + error.message().to_string() } -pub fn init_repo(path: &Path, is_worktree: bool) -> Result> { - let repo = match is_worktree { - false => Repository::init(path)?, - true => Repository::init_bare(path.join(super::GIT_MAIN_WORKTREE_DIRECTORY))?, - }; - - if is_worktree { - repo_set_config_push(&repo, "upstream")?; +impl Repo { + pub fn open(path: &Path, is_worktree: bool) -> Result { + let open_func = match is_worktree { + true => Repository::open_bare, + false => Repository::open, + }; + let path = match is_worktree { + true => path.join(crate::GIT_MAIN_WORKTREE_DIRECTORY), + false => path.to_path_buf(), + }; + match open_func(path) { + Ok(r) => Ok(Self(r)), + Err(e) => match e.code() { + git2::ErrorCode::NotFound => Err(RepoError::new(RepoErrorKind::NotFound)), + _ => Err(RepoError::new(RepoErrorKind::Unknown( + convert_libgit2_error(e), + ))), + }, + } } - Ok(repo) -} - -pub fn get_repo_config(repo: &git2::Repository) -> Result { - repo.config() - .map_err(|error| format!("Failed getting repository configuration: {}", error)) -} - -pub fn repo_make_bare(repo: &git2::Repository, value: bool) -> Result<(), String> { - let mut config = get_repo_config(repo)?; - - config - .set_bool(super::GIT_CONFIG_BARE_KEY, value) - .map_err(|error| format!("Could not set {}: {}", super::GIT_CONFIG_BARE_KEY, error)) -} - -pub fn repo_set_config_push(repo: &git2::Repository, value: &str) -> Result<(), String> { - let mut config = get_repo_config(repo)?; - - config - .set_str(super::GIT_CONFIG_PUSH_DEFAULT, value) - .map_err(|error| { - format!( - "Could not set {}: {}", - super::GIT_CONFIG_PUSH_DEFAULT, - error + pub fn graph_ahead_behind( + &self, + local_branch: &Branch, + remote_branch: &Branch, + ) -> Result<(usize, usize), String> { + self.0 + .graph_ahead_behind( + local_branch.commit()?.id().0, + remote_branch.commit()?.id().0, ) + .map_err(convert_libgit2_error) + } + + pub fn head_branch(&self) -> Result { + let head = self.0.head().map_err(convert_libgit2_error)?; + if !head.is_branch() { + return Err(String::from("No branch checked out")); + } + // unwrap() is safe here, as we can be certain that a branch with that + // name exists + let branch = self + .find_local_branch( + &head + .shorthand() + .expect("Branch name is not valid utf-8") + .to_string(), + ) + .unwrap(); + Ok(branch) + } + + pub fn remote_set_url(&self, name: &str, url: &str) -> Result<(), String> { + self.0 + .remote_set_url(name, url) + .map_err(convert_libgit2_error) + } + + pub fn remote_delete(&self, name: &str) -> Result<(), String> { + self.0.remote_delete(name).map_err(convert_libgit2_error) + } + + pub fn is_empty(&self) -> Result { + self.0.is_empty().map_err(convert_libgit2_error) + } + + pub fn is_bare(&self) -> bool { + self.0.is_bare() + } + + pub fn new_worktree( + &self, + name: &str, + directory: &Path, + target_branch: &Branch, + ) -> Result<(), String> { + self.0 + .worktree( + name, + directory, + Some(git2::WorktreeAddOptions::new().reference(Some(target_branch.as_reference()))), + ) + .map_err(convert_libgit2_error)?; + Ok(()) + } + + pub fn remotes(&self) -> Result, String> { + Ok(self + .0 + .remotes() + .map_err(convert_libgit2_error)? + .iter() + .map(|name| name.expect("Remote name is invalid utf-8")) + .map(|name| name.to_owned()) + .collect()) + } + + pub fn new_remote(&self, name: &str, url: &str) -> Result<(), String> { + self.0.remote(name, url).map_err(convert_libgit2_error)?; + Ok(()) + } + + pub fn init(path: &Path, is_worktree: bool) -> Result { + let repo = match is_worktree { + false => Repository::init(path).map_err(convert_libgit2_error)?, + true => Repository::init_bare(path.join(crate::GIT_MAIN_WORKTREE_DIRECTORY)) + .map_err(convert_libgit2_error)?, + }; + + let repo = Repo(repo); + + if is_worktree { + repo.set_config_push(GitPushDefaultSetting::Upstream)?; + } + + Ok(repo) + } + + pub fn config(&self) -> Result { + self.0.config().map_err(convert_libgit2_error) + } + + pub fn find_worktree(&self, name: &str) -> Result<(), String> { + self.0.find_worktree(name).map_err(convert_libgit2_error)?; + Ok(()) + } + + pub fn prune_worktree(&self, name: &str) -> Result<(), String> { + let worktree = self.0.find_worktree(name).map_err(convert_libgit2_error)?; + worktree.prune(None).map_err(convert_libgit2_error)?; + Ok(()) + } + + pub fn find_remote_branch(&self, remote_name: &str, branch_name: &str) -> Result { + Ok(Branch( + self.0 + .find_branch(&format!("{}/{}", remote_name, branch_name), git2::BranchType::Remote) + .map_err(convert_libgit2_error)?, + )) + } + + pub fn find_local_branch(&self, name: &str) -> Result { + Ok(Branch( + self.0 + .find_branch(name, git2::BranchType::Local) + .map_err(convert_libgit2_error)?, + )) + } + + pub fn create_branch(&self, name: &str, target: &Commit) -> Result { + Ok(Branch( + self.0 + .branch(name, &target.0, false) + .map_err(convert_libgit2_error)?, + )) + } + + pub fn make_bare(&self, value: bool) -> Result<(), String> { + let mut config = self.config()?; + + config + .set_bool(crate::GIT_CONFIG_BARE_KEY, value) + .map_err(|error| format!("Could not set {}: {}", crate::GIT_CONFIG_BARE_KEY, error)) + } + + pub fn convert_to_worktree(&self, root_dir: &Path) -> Result<(), String> { + std::fs::rename(".git", crate::GIT_MAIN_WORKTREE_DIRECTORY) + .map_err(|error| format!("Error moving .git directory: {}", error))?; + + for entry in match std::fs::read_dir(&root_dir) { + Ok(iterator) => iterator, + Err(error) => { + return Err(format!("Opening directory failed: {}", error)); + } + } { + match entry { + Ok(entry) => { + let path = entry.path(); + // unwrap is safe here, the path will ALWAYS have a file component + if path.file_name().unwrap() == crate::GIT_MAIN_WORKTREE_DIRECTORY { + continue; + } + if path.is_file() || path.is_symlink() { + if let Err(error) = std::fs::remove_file(&path) { + return Err(format!("Failed removing {}", error)); + } + } else if let Err(error) = std::fs::remove_dir_all(&path) { + return Err(format!("Failed removing {}", error)); + } + } + Err(error) => { + return Err(format!("Error getting directory entry: {}", error)); + } + } + } + + let worktree_repo = Repo::open(root_dir, true) + .map_err(|error| format!("Opening newly converted repository failed: {}", error))?; + + worktree_repo + .make_bare(true) + .map_err(|error| format!("Error: {}", error))?; + + worktree_repo + .set_config_push(GitPushDefaultSetting::Upstream) + .map_err(|error| format!("Error: {}", error))?; + + Ok(()) + } + + pub fn set_config_push(&self, value: GitPushDefaultSetting) -> Result<(), String> { + let mut config = self.config()?; + + config + .set_str(crate::GIT_CONFIG_PUSH_DEFAULT, match value { + GitPushDefaultSetting::Upstream => "upstream", + }) + .map_err(|error| { + format!( + "Could not set {}: {}", + crate::GIT_CONFIG_PUSH_DEFAULT, + error + ) + }) + } + + pub fn status(&self, is_worktree: bool) -> Result { + let operation = match self.0.state() { + git2::RepositoryState::Clean => None, + state => Some(state), + }; + + let empty = self.is_empty()?; + + let remotes = self + .0 + .remotes() + .map_err(convert_libgit2_error)? + .iter() + .map(|repo_name| repo_name.expect("Worktree name is invalid utf-8.")) + .map(|repo_name| repo_name.to_owned()) + .collect::>(); + + let head = match is_worktree { + true => None, + false => match empty { + true => None, + false => Some(self.head_branch()?.name()?), + }, + }; + + let changes = match is_worktree { + true => { + return Err(String::from( + "Cannot get changes as this is a bare worktree repository", + )) + } + false => { + let statuses = self + .0 + .statuses(Some( + git2::StatusOptions::new() + .include_ignored(false) + .include_untracked(true), + )) + .map_err(convert_libgit2_error)?; + + match statuses.is_empty() { + true => None, + false => { + let mut files_new = 0; + let mut files_modified = 0; + let mut files_deleted = 0; + for status in statuses.iter() { + let status_bits = status.status(); + if status_bits.intersects( + git2::Status::INDEX_MODIFIED + | git2::Status::INDEX_RENAMED + | git2::Status::INDEX_TYPECHANGE + | git2::Status::WT_MODIFIED + | git2::Status::WT_RENAMED + | git2::Status::WT_TYPECHANGE, + ) { + files_modified += 1; + } else if status_bits + .intersects(git2::Status::INDEX_NEW | git2::Status::WT_NEW) + { + files_new += 1; + } else if status_bits + .intersects(git2::Status::INDEX_DELETED | git2::Status::WT_DELETED) + { + files_deleted += 1; + } + } + if (files_new, files_modified, files_deleted) == (0, 0, 0) { + panic!( + "is_empty() returned true, but no file changes were detected. This is a bug!" + ); + } + Some(RepoChanges { + files_new, + files_modified, + files_deleted, + }) + } + } + } + }; + + let worktrees = self.0.worktrees().unwrap().len(); + + let submodules = match is_worktree { + true => None, + false => { + let mut submodules = Vec::new(); + for submodule in self.0.submodules().unwrap() { + let submodule_name = submodule.name().unwrap().to_string(); + + let submodule_status; + let status = self + .0 + .submodule_status(submodule.name().unwrap(), git2::SubmoduleIgnore::None) + .unwrap(); + + if status.intersects( + git2::SubmoduleStatus::WD_INDEX_MODIFIED + | git2::SubmoduleStatus::WD_WD_MODIFIED + | git2::SubmoduleStatus::WD_UNTRACKED, + ) { + submodule_status = SubmoduleStatus::Changed; + } else if status.is_wd_uninitialized() { + submodule_status = SubmoduleStatus::Uninitialized; + } else if status.is_wd_modified() { + submodule_status = SubmoduleStatus::OutOfDate; + } else { + submodule_status = SubmoduleStatus::Clean; + } + + submodules.push((submodule_name, submodule_status)); + } + Some(submodules) + } + }; + + let mut branches = Vec::new(); + for (local_branch, _) in self + .0 + .branches(Some(git2::BranchType::Local)) + .unwrap() + .map(|branch_name| branch_name.unwrap()) + { + let branch_name = local_branch.name().unwrap().unwrap().to_string(); + let remote_branch = match local_branch.upstream() { + Ok(remote_branch) => { + let remote_branch_name = remote_branch.name().unwrap().unwrap().to_string(); + + let (ahead, behind) = self + .0 + .graph_ahead_behind( + local_branch.get().peel_to_commit().unwrap().id(), + remote_branch.get().peel_to_commit().unwrap().id(), + ) + .unwrap(); + + let remote_tracking_status = match (ahead, behind) { + (0, 0) => RemoteTrackingStatus::UpToDate, + (0, d) => RemoteTrackingStatus::Behind(d), + (d, 0) => RemoteTrackingStatus::Ahead(d), + (d1, d2) => RemoteTrackingStatus::Diverged(d1, d2), + }; + Some((remote_branch_name, remote_tracking_status)) + } + // Err => no remote branch + Err(_) => None, + }; + branches.push((branch_name, remote_branch)); + } + + Ok(RepoStatus { + operation, + empty, + remotes, + head, + changes, + worktrees, + submodules, + branches, }) + } + + pub fn default_branch(&self) -> Result { + match self.0.find_branch("main", git2::BranchType::Local) { + Ok(branch) => Ok(Branch(branch)), + Err(_) => match self.0.find_branch("master", git2::BranchType::Local) { + Ok(branch) => Ok(Branch(branch)), + Err(_) => Err(String::from("Could not determine default branch")), + }, + } + } + + // Looks like there is no distinguishing between the error cases + // "no such remote" and "failed to get remote for some reason". + // May be a good idea to handle this explicitly, by returning a + // Result, String> instead, Returning Ok(None) + // on "not found" and Err() on an actual error. + pub fn find_remote(&self, remote_name: &str) -> Result, String> { + let remotes = self.0.remotes().map_err(convert_libgit2_error)?; + + if !remotes.iter().any(|remote| remote.expect("Remote name is invalid utf-8") == remote_name) { + return Ok(None) + } + + Ok(Some(RemoteHandle( + self.0 + .find_remote(remote_name) + .map_err(convert_libgit2_error)?, + ))) + } + + pub fn get_worktrees(&self) -> Result, String> { + Ok(self + .0 + .worktrees() + .map_err(convert_libgit2_error)? + .iter() + .map(|name| name.expect("Worktree name is invalid utf-8")) + .map(|name| name.to_string()) + .collect()) + } + + pub fn remove_worktree( + &self, + name: &str, + worktree_dir: &Path, + force: bool, + ) -> Result<(), WorktreeRemoveFailureReason> { + if !worktree_dir.exists() { + return Err(WorktreeRemoveFailureReason::Error(format!( + "{} does not exist", + name + ))); + } + let worktree_repo = Repo::open(worktree_dir, false).map_err(|error| { + WorktreeRemoveFailureReason::Error(format!("Error opening repo: {}", error)) + })?; + + let local_branch = worktree_repo.head_branch().map_err(|error| { + WorktreeRemoveFailureReason::Error(format!("Failed getting head branch: {}", error)) + })?; + + let branch_name = local_branch.name().map_err(|error| { + WorktreeRemoveFailureReason::Error(format!("Failed getting name of branch: {}", error)) + })?; + + if branch_name != name + && !branch_name.ends_with(&format!("{}{}", crate::BRANCH_NAMESPACE_SEPARATOR, name)) + { + return Err(WorktreeRemoveFailureReason::Error(format!( + "Branch {} is checked out in worktree, this does not look correct", + &branch_name + ))); + } + + let branch = worktree_repo + .find_local_branch(&branch_name) + .map_err(WorktreeRemoveFailureReason::Error)?; + + if !force { + let status = worktree_repo + .status(false) + .map_err(WorktreeRemoveFailureReason::Error)?; + if status.changes.is_some() { + return Err(WorktreeRemoveFailureReason::Changes(String::from( + "Changes found in worktree", + ))); + } + + match branch.upstream() { + Ok(remote_branch) => { + let (ahead, behind) = worktree_repo + .graph_ahead_behind(&branch, &remote_branch) + .unwrap(); + + if (ahead, behind) != (0, 0) { + return Err(WorktreeRemoveFailureReason::Changes(format!( + "Branch {} is not in line with remote branch", + name + ))); + } + } + Err(_) => { + return Err(WorktreeRemoveFailureReason::Changes(format!( + "No remote tracking branch for branch {} found", + name + ))); + } + } + } + + if let Err(e) = std::fs::remove_dir_all(&worktree_dir) { + return Err(WorktreeRemoveFailureReason::Error(format!( + "Error deleting {}: {}", + &worktree_dir.display(), + e + ))); + } + self.prune_worktree(name) + .map_err(WorktreeRemoveFailureReason::Error)?; + branch + .delete() + .map_err(WorktreeRemoveFailureReason::Error)?; + + Ok(()) + } + + pub fn cleanup_worktrees(&self, directory: &Path) -> Result, String> { + let mut warnings = Vec::new(); + + let worktrees = self + .get_worktrees() + .map_err(|error| format!("Getting worktrees failed: {}", error))?; + + let default_branch = self + .default_branch() + .map_err(|error| format!("Failed getting default branch: {}", error))?; + + let default_branch_name = default_branch + .name() + .map_err(|error| format!("Failed getting default branch name: {}", error))?; + + for worktree in worktrees + .iter() + .filter(|worktree| *worktree != &default_branch_name) + { + let repo_dir = &directory.join(&worktree); + if repo_dir.exists() { + match self.remove_worktree(worktree, repo_dir, false) { + Ok(_) => print_success(&format!("Worktree {} deleted", &worktree)), + Err(error) => match error { + WorktreeRemoveFailureReason::Changes(changes) => { + warnings.push(format!( + "Changes found in {}: {}, skipping", + &worktree, &changes + )); + continue; + } + WorktreeRemoveFailureReason::Error(error) => { + return Err(error); + } + }, + } + } else { + warnings.push(format!("Worktree {} does not have a directory", &worktree)); + } + } + Ok(warnings) + } + + pub fn find_unmanaged_worktrees(&self, directory: &Path) -> Result, String> { + let worktrees = self + .get_worktrees() + .map_err(|error| format!("Getting worktrees failed: {}", error))?; + + let mut unmanaged_worktrees = Vec::new(); + for entry in std::fs::read_dir(&directory).map_err(|error| error.to_string())? { + let dirname = crate::path_as_string( + &entry.map_err(|error| error.to_string())? + .path() + .strip_prefix(&directory) + // that unwrap() is safe as each entry is + // guaranteed to be a subentry of &directory + .unwrap() + .to_path_buf(), + ); + + let default_branch = self + .default_branch() + .map_err(|error| format!("Failed getting default branch: {}", error))?; + + let default_branch_name = default_branch + .name() + .map_err(|error| format!("Failed getting default branch name: {}", error))?; + + if dirname == crate::GIT_MAIN_WORKTREE_DIRECTORY { + continue; + } + if dirname == default_branch_name { + continue; + } + if !&worktrees.contains(&dirname) { + unmanaged_worktrees.push(dirname); + } + } + Ok(unmanaged_worktrees) + } + + pub fn detect_worktree(path: &Path) -> bool { + path.join(crate::GIT_MAIN_WORKTREE_DIRECTORY).exists() + } +} + +pub struct RemoteHandle<'a>(git2::Remote<'a>); +pub struct Commit<'a>(git2::Commit<'a>); +pub struct Reference<'a>(git2::Reference<'a>); +pub struct Oid(git2::Oid); + +impl Oid { + pub fn hex_string(&self) -> String { + self.0.to_string() + } +} + +impl Commit<'_> { + pub fn id(&self) -> Oid { + Oid(self.0.id()) + } +} + +impl<'a> Branch<'a> { + pub fn to_commit(self) -> Result, String> { + Ok(Commit( + self.0 + .into_reference() + .peel_to_commit() + .map_err(convert_libgit2_error)?, + )) + } +} + +impl Branch<'_> { + pub fn commit(&self) -> Result { + Ok(Commit( + self.0 + .get() + .peel_to_commit() + .map_err(convert_libgit2_error)?, + )) + } + + pub fn set_upstream(&mut self, remote_name: &str, branch_name: &str) -> Result<(), String> { + self.0 + .set_upstream(Some(&format!("{}/{}", remote_name, branch_name))) + .map_err(convert_libgit2_error)?; + Ok(()) + } + + pub fn name(&self) -> Result { + self.0 + .name() + .map(|name| name.expect("Branch name is invalid utf-8")) + .map_err(convert_libgit2_error) + .map(|name| name.to_string()) + } + + pub fn upstream(&self) -> Result { + Ok(Branch(self.0.upstream().map_err(convert_libgit2_error)?)) + } + + pub fn delete(mut self) -> Result<(), String> { + self.0.delete().map_err(convert_libgit2_error) + } + + // only used internally in this module, exposes libgit2 details + fn as_reference(&self) -> &git2::Reference { + self.0.get() + } +} + +impl RemoteHandle<'_> { + pub fn url(&self) -> String { + self.0 + .url() + .expect("Remote URL is invalid utf-8") + .to_string() + } + + pub fn name(&self) -> String { + self.0 + .name() + .expect("Remote name is invalid utf-8") + .to_string() + } + + pub fn push( + &mut self, + local_branch_name: &str, + remote_branch_name: &str, + _repo: &Repo, + ) -> Result<(), String> { + let mut callbacks = git2::RemoteCallbacks::new(); + callbacks.push_update_reference(|_, status| { + if let Some(message) = status { + return Err(git2::Error::new( + git2::ErrorCode::GenericError, + git2::ErrorClass::None, + message, + )); + } + Ok(()) + }); + callbacks.credentials(|_url, username_from_url, _allowed_types| { + git2::Cred::ssh_key_from_agent(username_from_url.unwrap()) + }); + + let mut push_options = git2::PushOptions::new(); + push_options.remote_callbacks(callbacks); + + let push_refspec = format!( + "+refs/heads/{}:refs/heads/{}", + local_branch_name, remote_branch_name + ); + self.0 + .push(&[push_refspec], Some(&mut push_options)) + .map_err(|error| { + format!( + "Pushing {} to {} ({}) failed: {}", + local_branch_name, + self.name(), + self.url(), + error + ) + })?; + Ok(()) + } } pub fn clone_repo( @@ -248,7 +927,7 @@ pub fn clone_repo( ) -> Result<(), Box> { let clone_target = match is_worktree { false => path.to_path_buf(), - true => path.join(super::GIT_MAIN_WORKTREE_DIRECTORY), + true => path.join(crate::GIT_MAIN_WORKTREE_DIRECTORY), }; print_action(&format!( @@ -280,163 +959,9 @@ pub fn clone_repo( } if is_worktree { - let repo = open_repo(&clone_target, false)?; - repo_set_config_push(&repo, "upstream")?; + let repo = Repo::open(&clone_target, false)?; + repo.set_config_push(GitPushDefaultSetting::Upstream)?; } Ok(()) } - -pub fn get_repo_status(repo: &git2::Repository, is_worktree: bool) -> RepoStatus { - let operation = match repo.state() { - git2::RepositoryState::Clean => None, - state => Some(state), - }; - - let empty = repo.is_empty().unwrap(); - - let remotes = repo - .remotes() - .unwrap() - .iter() - .map(|repo_name| repo_name.unwrap().to_string()) - .collect::>(); - - let head = match is_worktree { - true => None, - false => match empty { - true => None, - false => Some(repo.head().unwrap().shorthand().unwrap().to_string()), - }, - }; - - let changes = match is_worktree { - true => None, - false => { - let statuses = repo - .statuses(Some( - git2::StatusOptions::new() - .include_ignored(false) - .include_untracked(true), - )) - .unwrap(); - - match statuses.is_empty() { - true => Some(None), - false => { - let mut files_new = 0; - let mut files_modified = 0; - let mut files_deleted = 0; - for status in statuses.iter() { - let status_bits = status.status(); - if status_bits.intersects( - git2::Status::INDEX_MODIFIED - | git2::Status::INDEX_RENAMED - | git2::Status::INDEX_TYPECHANGE - | git2::Status::WT_MODIFIED - | git2::Status::WT_RENAMED - | git2::Status::WT_TYPECHANGE, - ) { - files_modified += 1; - } else if status_bits - .intersects(git2::Status::INDEX_NEW | git2::Status::WT_NEW) - { - files_new += 1; - } else if status_bits - .intersects(git2::Status::INDEX_DELETED | git2::Status::WT_DELETED) - { - files_deleted += 1; - } - } - if (files_new, files_modified, files_deleted) == (0, 0, 0) { - panic!( - "is_empty() returned true, but no file changes were detected. This is a bug!" - ); - } - Some(Some(RepoChanges { - files_new, - files_modified, - files_deleted, - })) - } - } - } - }; - - let worktrees = repo.worktrees().unwrap().len(); - - let submodules = match is_worktree { - true => None, - false => { - let mut submodules = Vec::new(); - for submodule in repo.submodules().unwrap() { - let submodule_name = submodule.name().unwrap().to_string(); - - let submodule_status; - let status = repo - .submodule_status(submodule.name().unwrap(), git2::SubmoduleIgnore::None) - .unwrap(); - - if status.intersects( - git2::SubmoduleStatus::WD_INDEX_MODIFIED - | git2::SubmoduleStatus::WD_WD_MODIFIED - | git2::SubmoduleStatus::WD_UNTRACKED, - ) { - submodule_status = SubmoduleStatus::Changed; - } else if status.is_wd_uninitialized() { - submodule_status = SubmoduleStatus::Uninitialized; - } else if status.is_wd_modified() { - submodule_status = SubmoduleStatus::OutOfDate; - } else { - submodule_status = SubmoduleStatus::Clean; - } - - submodules.push((submodule_name, submodule_status)); - } - Some(submodules) - } - }; - - let mut branches = Vec::new(); - for (local_branch, _) in repo - .branches(Some(git2::BranchType::Local)) - .unwrap() - .map(|branch_name| branch_name.unwrap()) - { - let branch_name = local_branch.name().unwrap().unwrap().to_string(); - let remote_branch = match local_branch.upstream() { - Ok(remote_branch) => { - let remote_branch_name = remote_branch.name().unwrap().unwrap().to_string(); - - let (ahead, behind) = repo - .graph_ahead_behind( - local_branch.get().peel_to_commit().unwrap().id(), - remote_branch.get().peel_to_commit().unwrap().id(), - ) - .unwrap(); - - let remote_tracking_status = match (ahead, behind) { - (0, 0) => RemoteTrackingStatus::UpToDate, - (0, d) => RemoteTrackingStatus::Behind(d), - (d, 0) => RemoteTrackingStatus::Ahead(d), - (d1, d2) => RemoteTrackingStatus::Diverged(d1, d2), - }; - Some((remote_branch_name, remote_tracking_status)) - } - // Err => no remote branch - Err(_) => None, - }; - branches.push((branch_name, remote_branch)); - } - - RepoStatus { - operation, - empty, - remotes, - head, - changes, - worktrees, - submodules, - branches, - } -} diff --git a/src/table.rs b/src/table.rs new file mode 100644 index 0000000..9b94c5e --- /dev/null +++ b/src/table.rs @@ -0,0 +1,311 @@ +use comfy_table::{Cell, Table}; + +use std::path::Path; + +fn add_table_header(table: &mut Table) { + table + .load_preset(comfy_table::presets::UTF8_FULL) + .apply_modifier(comfy_table::modifiers::UTF8_ROUND_CORNERS) + .set_header(vec![ + Cell::new("Repo"), + Cell::new("Worktree"), + Cell::new("Status"), + Cell::new("Branches"), + Cell::new("HEAD"), + Cell::new("Remotes"), + ]); +} + +fn add_repo_status( + table: &mut Table, + repo_name: &str, + repo_handle: &crate::Repo, + is_worktree: bool, +) -> Result<(), String> { + let repo_status = repo_handle.status(is_worktree)?; + + table.add_row(vec![ + repo_name, + match is_worktree { + true => "\u{2714}", + false => "", + }, + &match repo_status.changes { + Some(changes) => { + let mut out = Vec::new(); + if changes.files_new > 0 { + out.push(format!("New: {}\n", changes.files_new)) + } + if changes.files_modified > 0 { + out.push(format!("Modified: {}\n", changes.files_modified)) + } + if changes.files_deleted > 0 { + out.push(format!("Deleted: {}\n", changes.files_deleted)) + } + out.into_iter().collect::().trim().to_string() + } + None => String::from("\u{2714}"), + }, + &repo_status + .branches + .iter() + .map(|(branch_name, remote_branch)| { + format!( + "branch: {}{}\n", + &branch_name, + &match remote_branch { + None => String::from(" "), + Some((remote_branch_name, remote_tracking_status)) => { + format!( + " <{}>{}", + remote_branch_name, + &match remote_tracking_status { + crate::RemoteTrackingStatus::UpToDate => + String::from(" \u{2714}"), + crate::RemoteTrackingStatus::Ahead(d) => format!(" [+{}]", &d), + crate::RemoteTrackingStatus::Behind(d) => format!(" [-{}]", &d), + crate::RemoteTrackingStatus::Diverged(d1, d2) => + format!(" [+{}/-{}]", &d1, &d2), + } + ) + } + } + ) + }) + .collect::() + .trim() + .to_string(), + &match is_worktree { + true => String::from(""), + false => match repo_status.head { + Some(head) => head, + None => String::from("Empty"), + }, + }, + &repo_status + .remotes + .iter() + .map(|r| format!("{}\n", r)) + .collect::() + .trim() + .to_string(), + ]); + + Ok(()) +} + +// Don't return table, return a type that implements Display(?) +pub fn get_worktree_status_table( + repo: &crate::Repo, + directory: &Path, +) -> Result<(impl std::fmt::Display, Vec), String> { + let worktrees = repo.get_worktrees()?; + let mut table = Table::new(); + + let mut errors = Vec::new(); + + add_worktree_table_header(&mut table); + for worktree in &worktrees { + let worktree_dir = &directory.join(&worktree); + if worktree_dir.exists() { + let repo = match crate::Repo::open(worktree_dir, false) { + Ok(repo) => repo, + Err(error) => { + errors.push(format!( + "Failed opening repo of worktree {}: {}", + &worktree, &error + )); + continue; + } + }; + if let Err(error) = add_worktree_status(&mut table, worktree, &repo) { + errors.push(error); + } + } else { + errors.push(format!("Worktree {} does not have a directory", &worktree)); + } + } + for entry in std::fs::read_dir(&directory).map_err(|error| error.to_string())? { + let dirname = crate::path_as_string( + &entry + .map_err(|error| error.to_string())? + .path() + .strip_prefix(&directory) + // this unwrap is safe, as we can be sure that each subentry of + // &directory also has the prefix &dir + .unwrap() + .to_path_buf(), + ); + if dirname == crate::GIT_MAIN_WORKTREE_DIRECTORY { + continue; + } + if !&worktrees.contains(&dirname) { + errors.push(format!( + "Found {}, which is not a valid worktree directory!", + &dirname + )); + } + } + Ok((table, errors)) +} + +pub fn get_status_table(config: crate::Config) -> Result<(Vec, Vec), String> { + let mut errors = Vec::new(); + let mut tables = Vec::new(); + for tree in config.trees.as_vec() { + let repos = tree.repos.unwrap_or_default(); + + let root_path = crate::expand_path(Path::new(&tree.root)); + + let mut table = Table::new(); + add_table_header(&mut table); + + for repo in &repos { + let repo_path = root_path.join(&repo.name); + + if !repo_path.exists() { + errors.push(format!( + "{}: Repository does not exist. Run sync?", + &repo.name + )); + continue; + } + + let repo_handle = crate::Repo::open(&repo_path, repo.worktree_setup); + + let repo_handle = match repo_handle { + Ok(repo) => repo, + Err(error) => { + if error.kind == crate::RepoErrorKind::NotFound { + errors.push(format!( + "{}: No git repository found. Run sync?", + &repo.name + )); + } else { + errors.push(format!( + "{}: Opening repository failed: {}", + &repo.name, error + )); + } + continue; + } + }; + + add_repo_status(&mut table, &repo.name, &repo_handle, repo.worktree_setup)?; + } + + tables.push(table); + } + + Ok((tables, errors)) +} + +fn add_worktree_table_header(table: &mut Table) { + table + .load_preset(comfy_table::presets::UTF8_FULL) + .apply_modifier(comfy_table::modifiers::UTF8_ROUND_CORNERS) + .set_header(vec![ + Cell::new("Worktree"), + Cell::new("Status"), + Cell::new("Branch"), + Cell::new("Remote branch"), + ]); +} + +fn add_worktree_status( + table: &mut Table, + worktree_name: &str, + repo: &crate::Repo, +) -> Result<(), String> { + let repo_status = repo.status(false)?; + + let local_branch = repo + .head_branch() + .map_err(|error| format!("Failed getting head branch: {}", error))?; + + let upstream_output = match local_branch.upstream() { + Ok(remote_branch) => { + let remote_branch_name = remote_branch + .name() + .map_err(|error| format!("Failed getting name of remote branch: {}", error))?; + + let (ahead, behind) = repo + .graph_ahead_behind(&local_branch, &remote_branch) + .map_err(|error| format!("Failed computing branch deviation: {}", error))?; + + format!( + "{}{}\n", + &remote_branch_name, + &match (ahead, behind) { + (0, 0) => String::from(""), + (d, 0) => format!(" [+{}]", &d), + (0, d) => format!(" [-{}]", &d), + (d1, d2) => format!(" [+{}/-{}]", &d1, &d2), + }, + ) + } + Err(_) => String::from(""), + }; + + table.add_row(vec![ + worktree_name, + &match repo_status.changes { + Some(changes) => { + let mut out = Vec::new(); + if changes.files_new > 0 { + out.push(format!("New: {}\n", changes.files_new)) + } + if changes.files_modified > 0 { + out.push(format!("Modified: {}\n", changes.files_modified)) + } + if changes.files_deleted > 0 { + out.push(format!("Deleted: {}\n", changes.files_deleted)) + } + out.into_iter().collect::().trim().to_string() + } + None => String::from("\u{2714}"), + }, + &local_branch + .name() + .map_err(|error| format!("Failed getting name of branch: {}", error))?, + &upstream_output, + ]); + + Ok(()) +} + +pub fn show_single_repo_status(path: &Path) -> Result<(impl std::fmt::Display, Vec), String> { + let mut table = Table::new(); + let mut warnings = Vec::new(); + + let is_worktree = crate::Repo::detect_worktree(path); + add_table_header(&mut table); + + let repo_handle = crate::Repo::open(path, is_worktree); + + if let Err(error) = repo_handle { + if error.kind == crate::RepoErrorKind::NotFound { + return Err(String::from("Directory is not a git directory")); + } else { + return Err(format!("Opening repository failed: {}", error)); + } + }; + + let repo_name = match path.file_name() { + None => { + warnings.push(format!("Cannot detect repo name for path {}. Are you working in /?", &path.display())); + String::from("unknown") + } + Some(file_name) => match file_name.to_str() { + None => { + warnings.push(format!("Name of repo directory {} is not valid UTF-8", &path.display())); + String::from("invalid") + } + Some(name) => name.to_string(), + }, + }; + + add_repo_status(&mut table, &repo_name, &repo_handle.unwrap(), is_worktree)?; + + Ok((table, warnings)) +} diff --git a/tests/repo.rs b/tests/repo.rs index 261fd18..5027b4f 100644 --- a/tests/repo.rs +++ b/tests/repo.rs @@ -8,13 +8,13 @@ use helpers::*; fn open_empty_repo() { let tmpdir = init_tmpdir(); assert!(matches!( - open_repo(tmpdir.path(), true), + RepoHandle::open(tmpdir.path(), true), Err(RepoError { kind: RepoErrorKind::NotFound }) )); assert!(matches!( - open_repo(tmpdir.path(), false), + RepoHandle::open(tmpdir.path(), false), Err(RepoError { kind: RepoErrorKind::NotFound }) @@ -25,7 +25,7 @@ fn open_empty_repo() { #[test] fn create_repo() -> Result<(), Box> { let tmpdir = init_tmpdir(); - let repo = init_repo(tmpdir.path(), false)?; + let repo = RepoHandle::init(tmpdir.path(), false)?; assert!(!repo.is_bare()); assert!(repo.is_empty()?); cleanup_tmpdir(tmpdir); @@ -35,7 +35,7 @@ fn create_repo() -> Result<(), Box> { #[test] fn create_repo_with_worktree() -> Result<(), Box> { let tmpdir = init_tmpdir(); - let repo = init_repo(tmpdir.path(), true)?; + let repo = RepoHandle::init(tmpdir.path(), true)?; assert!(repo.is_bare()); assert!(repo.is_empty()?); cleanup_tmpdir(tmpdir); From a51e5f891890fda27ab644af99498c3d56b917e8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hannes=20K=C3=B6rber?= Date: Thu, 2 Dec 2021 12:43:45 +0100 Subject: [PATCH 10/68] Fix regression of find with broken repos --- src/grm/main.rs | 7 +++++-- src/lib.rs | 43 ++++++++++++++++++++++++++++--------------- 2 files changed, 33 insertions(+), 17 deletions(-) diff --git a/src/grm/main.rs b/src/grm/main.rs index 203d82c..b021a80 100644 --- a/src/grm/main.rs +++ b/src/grm/main.rs @@ -95,8 +95,8 @@ fn main() { } }; - let found_repos = match grm::find_in_tree(&path) { - Ok(repos) => repos, + let (found_repos, warnings) = match grm::find_in_tree(&path) { + Ok((repos, warnings)) => (repos, warnings), Err(error) => { print_error(&error); process::exit(1); @@ -122,6 +122,9 @@ fn main() { print!("{}", toml); } + for warning in warnings { + print_warning(&warning); + } } }, cmd::SubCommand::Worktree(args) => { diff --git a/src/lib.rs b/src/lib.rs index 4b78967..36d7898 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -323,9 +323,10 @@ fn get_actual_git_directory(path: &Path, is_worktree: bool) -> PathBuf { /// /// The bool in the return value specifies whether there is a repository /// in root itself. -fn find_repos(root: &Path) -> Result, bool)>, String> { +fn find_repos(root: &Path) -> Result, Vec, bool)>, String> { let mut repos: Vec = Vec::new(); let mut repo_in_root = false; + let mut warnings = Vec::new(); for path in find_repo_paths(root)? { let is_worktree = Repo::detect_worktree(&path); @@ -335,7 +336,7 @@ fn find_repos(root: &Path) -> Result, bool)>, String> { match Repo::open(&path, is_worktree) { Err(error) => { - return Err(format!( + warnings.push(format!( "Error opening repo {}{}: {}", path.display(), match is_worktree { @@ -343,12 +344,17 @@ fn find_repos(root: &Path) -> Result, bool)>, String> { false => "", }, error - )) + )); + continue }, Ok(repo) => { - let remotes = repo.remotes().map_err(|error| { - format!("{}: Error getting remotes: {}", &path_as_string(&path), error) - })?; + let remotes = match repo.remotes() { + Ok(remote) => remote, + Err(error) => { + warnings.push(format!("{}: Error getting remotes: {}", &path_as_string(&path), error)); + continue + } + }; let mut results: Vec = Vec::new(); for remote_name in remotes.iter() { @@ -359,7 +365,8 @@ fn find_repos(root: &Path) -> Result, bool)>, String> { let remote_type = match detect_remote_type(&url) { Some(t) => t, None => { - return Err(format!("{}: Could not detect remote type of \"{}\"", &path_as_string(&path), &url)); + warnings.push(format!("{}: Could not detect remote type of \"{}\"", &path_as_string(&path), &url)); + continue } }; @@ -370,7 +377,8 @@ fn find_repos(root: &Path) -> Result, bool)>, String> { }); } None => { - return Err(format!("{}: Remote {} not found", &path_as_string(&path), remote_name)); + warnings.push(format!("{}: Remote {} not found", &path_as_string(&path), remote_name)); + continue } }; } @@ -381,8 +389,8 @@ fn find_repos(root: &Path) -> Result, bool)>, String> { true => match &root.parent() { Some(parent) => path_as_string(path.strip_prefix(parent).unwrap()), None => { - print_error("Getting name of the search root failed. Do you have a git repository in \"/\"?"); - process::exit(1); + warnings.push(String::from("Getting name of the search root failed. Do you have a git repository in \"/\"?")); + continue }, } false => path_as_string(path.strip_prefix(&root).unwrap()), @@ -394,12 +402,17 @@ fn find_repos(root: &Path) -> Result, bool)>, String> { } } - Ok(Some((repos, repo_in_root))) + Ok(Some((repos, warnings, repo_in_root))) } -pub fn find_in_tree(path: &Path) -> Result { +pub fn find_in_tree(path: &Path) -> Result<(Tree, Vec), String> { + let mut warnings = Vec::new(); + let (repos, repo_in_root): (Vec, bool) = match find_repos(path)? { - Some((vec, repo_in_root)) => (vec, repo_in_root), + Some((vec, mut repo_warnings, repo_in_root)) => { + warnings.append(&mut repo_warnings); + (vec, repo_in_root) + } None => (Vec::new(), false), }; @@ -424,10 +437,10 @@ pub fn find_in_tree(path: &Path) -> Result { root = Path::new("~").join(root.strip_prefix(&home).unwrap()); } - Ok(Tree { + Ok((Tree { root: root.into_os_string().into_string().unwrap(), repos: Some(repos), - }) + }, warnings)) } pub fn add_worktree( From 4eb88260c8a28f3e2f01ef1fd943d69e2c336f89 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hannes=20K=C3=B6rber?= Date: Thu, 2 Dec 2021 12:43:53 +0100 Subject: [PATCH 11/68] Fix missed rename of RepoHandle --- tests/repo.rs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/repo.rs b/tests/repo.rs index 5027b4f..efcb10e 100644 --- a/tests/repo.rs +++ b/tests/repo.rs @@ -8,13 +8,13 @@ use helpers::*; fn open_empty_repo() { let tmpdir = init_tmpdir(); assert!(matches!( - RepoHandle::open(tmpdir.path(), true), + Repo::open(tmpdir.path(), true), Err(RepoError { kind: RepoErrorKind::NotFound }) )); assert!(matches!( - RepoHandle::open(tmpdir.path(), false), + Repo::open(tmpdir.path(), false), Err(RepoError { kind: RepoErrorKind::NotFound }) @@ -25,7 +25,7 @@ fn open_empty_repo() { #[test] fn create_repo() -> Result<(), Box> { let tmpdir = init_tmpdir(); - let repo = RepoHandle::init(tmpdir.path(), false)?; + let repo = Repo::init(tmpdir.path(), false)?; assert!(!repo.is_bare()); assert!(repo.is_empty()?); cleanup_tmpdir(tmpdir); @@ -35,7 +35,7 @@ fn create_repo() -> Result<(), Box> { #[test] fn create_repo_with_worktree() -> Result<(), Box> { let tmpdir = init_tmpdir(); - let repo = RepoHandle::init(tmpdir.path(), true)?; + let repo = Repo::init(tmpdir.path(), true)?; assert!(repo.is_bare()); assert!(repo.is_empty()?); cleanup_tmpdir(tmpdir); From 4722d5a8ff5dab0ea5a3ea6b351dc00f94087d8c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hannes=20K=C3=B6rber?= Date: Tue, 21 Dec 2021 16:15:12 +0100 Subject: [PATCH 12/68] Fix dependencies to exact version --- Cargo.toml | 18 +++++++++--------- depcheck/update-cargo-dependencies.py | 4 ++-- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 8a93b6e..6c78c48 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -37,29 +37,29 @@ path = "src/grm/main.rs" [dependencies] [dependencies.toml] -version = "0.5.8" +version = "=0.5.8" [dependencies.serde] -version = "1.0.130" +version = "=1.0.130" features = ["derive"] [dependencies.git2] -version = "0.13.24" +version = "=0.13.24" [dependencies.shellexpand] -version = "2.1.0" +version = "=2.1.0" [dependencies.clap] -version = "3.0.0-beta.5" +version = "=3.0.0-beta.5" [dependencies.console] -version = "0.15.0" +version = "=0.15.0" [dependencies.regex] -version = "1.5.4" +version = "=1.5.4" [dependencies.comfy-table] -version = "5.0.0" +version = "=5.0.0" [dev-dependencies.tempdir] -version = "0.3.7" +version = "=0.3.7" diff --git a/depcheck/update-cargo-dependencies.py b/depcheck/update-cargo-dependencies.py index c146432..acb8605 100755 --- a/depcheck/update-cargo-dependencies.py +++ b/depcheck/update-cargo-dependencies.py @@ -31,7 +31,7 @@ update_necessary = False for tier in ["dependencies", "dev-dependencies"]: for name, dependency in cargo[tier].items(): - version = dependency["version"] + version = dependency["version"].lstrip("=") if len(name) >= 4: info_file = f"{INDEX_DIR}/{name[0:2]}/{name[2:4]}/{name}" elif len(name) == 3: @@ -62,7 +62,7 @@ for tier in ["dependencies", "dev-dependencies"]: print( f"{name}: New version found: {latest_version} (current {current_version})" ) - cargo[tier][name]["version"] = str(latest_version) + cargo[tier][name]["version"] = f"={str(latest_version)}" if update_necessary is True: From 59c6164c1f1ff252e85b3ef2678f630bd42c7b47 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hannes=20K=C3=B6rber?= Date: Tue, 21 Dec 2021 16:15:12 +0100 Subject: [PATCH 13/68] depcheck: Commit updates automatically --- depcheck/update-cargo-dependencies.py | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/depcheck/update-cargo-dependencies.py b/depcheck/update-cargo-dependencies.py index acb8605..02acb84 100755 --- a/depcheck/update-cargo-dependencies.py +++ b/depcheck/update-cargo-dependencies.py @@ -63,12 +63,16 @@ for tier in ["dependencies", "dev-dependencies"]: f"{name}: New version found: {latest_version} (current {current_version})" ) cargo[tier][name]["version"] = f"={str(latest_version)}" + with open("../Cargo.toml", "w") as cargo_config: + cargo_config.write(tomlkit.dumps(cargo)) + + message = f"dependencies: Update {name} to {latest_version}" + subprocess.run( + ["git", "commit", "--message", message, "../Cargo.toml"], + check=True, + capture_output=True + ) -if update_necessary is True: - with open("../Cargo.toml", "w") as cargo_config: - cargo_config.write(tomlkit.dumps(cargo)) - sys.exit(1) -else: +if update_necessary is False: print("Everything up to date") - sys.exit(0) From 72dd861677773a82bf72b4f22a79b5c03ad80eb3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hannes=20K=C3=B6rber?= Date: Tue, 21 Dec 2021 16:15:12 +0100 Subject: [PATCH 14/68] dependencies: Update serde to 1.0.131 --- Cargo.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Cargo.toml b/Cargo.toml index 6c78c48..fe9aba2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -40,7 +40,7 @@ path = "src/grm/main.rs" version = "=0.5.8" [dependencies.serde] -version = "=1.0.130" +version = "=1.0.131" features = ["derive"] [dependencies.git2] From 6d747d8e89d5f236b744c837786866b9cc5e48a6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hannes=20K=C3=B6rber?= Date: Tue, 21 Dec 2021 16:15:12 +0100 Subject: [PATCH 15/68] dependencies: Update git2 to 0.13.25 --- Cargo.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Cargo.toml b/Cargo.toml index fe9aba2..a1ee0d6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -44,7 +44,7 @@ version = "=1.0.131" features = ["derive"] [dependencies.git2] -version = "=0.13.24" +version = "=0.13.25" [dependencies.shellexpand] version = "=2.1.0" From 0b181b9b7914115e890d373130acc58d8ce45cd5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hannes=20K=C3=B6rber?= Date: Tue, 21 Dec 2021 16:15:12 +0100 Subject: [PATCH 16/68] Run cargo fmt with new cargo version --- src/grm/main.rs | 9 +++++---- src/lib.rs | 44 +++++++++++++++++++++++++++++--------------- src/repo.rs | 32 +++++++++++++++++++++++--------- src/table.rs | 14 +++++++++++--- 4 files changed, 68 insertions(+), 31 deletions(-) diff --git a/src/grm/main.rs b/src/grm/main.rs index b021a80..046eb89 100644 --- a/src/grm/main.rs +++ b/src/grm/main.rs @@ -139,9 +139,10 @@ fn main() { Some(branch) => { let split = branch.split_once('/'); - if split.is_none() || - split.unwrap().0.len() == 0 - ||split.unwrap().1.len() == 0 { + if split.is_none() + || split.unwrap().0.len() == 0 + || split.unwrap().1.len() == 0 + { print_error("Tracking branch needs to match the pattern /"); process::exit(1); }; @@ -256,7 +257,7 @@ fn main() { for warning in warnings { print_warning(&warning); } - }, + } Err(error) => { print_error(&format!("Worktree cleanup failed: {}", error)); process::exit(1); diff --git a/src/lib.rs b/src/lib.rs index 36d7898..fded766 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -14,7 +14,7 @@ use output::*; use repo::{clone_repo, detect_remote_type, Remote, RepoConfig}; -pub use repo::{RemoteTrackingStatus, RepoErrorKind, Repo, WorktreeRemoveFailureReason}; +pub use repo::{RemoteTrackingStatus, Repo, RepoErrorKind, WorktreeRemoveFailureReason}; const GIT_MAIN_WORKTREE_DIRECTORY: &str = ".git-main-working-tree"; const BRANCH_NAMESPACE_SEPARATOR: &str = "/"; @@ -177,7 +177,7 @@ fn sync_repo(root_path: &Path, repo: &RepoConfig) -> Result<(), String> { return Err(format!("Repository failed during setting of the remote URL for remote \"{}\": {}", &remote.name, e)); }; } - }, + } None => { print_repo_action( &repo.name, @@ -345,14 +345,18 @@ fn find_repos(root: &Path) -> Result, Vec, bool) }, error )); - continue - }, + continue; + } Ok(repo) => { let remotes = match repo.remotes() { Ok(remote) => remote, Err(error) => { - warnings.push(format!("{}: Error getting remotes: {}", &path_as_string(&path), error)); - continue + warnings.push(format!( + "{}: Error getting remotes: {}", + &path_as_string(&path), + error + )); + continue; } }; @@ -365,8 +369,12 @@ fn find_repos(root: &Path) -> Result, Vec, bool) let remote_type = match detect_remote_type(&url) { Some(t) => t, None => { - warnings.push(format!("{}: Could not detect remote type of \"{}\"", &path_as_string(&path), &url)); - continue + warnings.push(format!( + "{}: Could not detect remote type of \"{}\"", + &path_as_string(&path), + &url + )); + continue; } }; @@ -377,8 +385,12 @@ fn find_repos(root: &Path) -> Result, Vec, bool) }); } None => { - warnings.push(format!("{}: Remote {} not found", &path_as_string(&path), remote_name)); - continue + warnings.push(format!( + "{}: Remote {} not found", + &path_as_string(&path), + remote_name + )); + continue; } }; } @@ -400,7 +412,6 @@ fn find_repos(root: &Path) -> Result, Vec, bool) }); } } - } Ok(Some((repos, warnings, repo_in_root))) } @@ -437,10 +448,13 @@ pub fn find_in_tree(path: &Path) -> Result<(Tree, Vec), String> { root = Path::new("~").join(root.strip_prefix(&home).unwrap()); } - Ok((Tree { - root: root.into_os_string().into_string().unwrap(), - repos: Some(repos), - }, warnings)) + Ok(( + Tree { + root: root.into_os_string().into_string().unwrap(), + repos: Some(repos), + }, + warnings, + )) } pub fn add_worktree( diff --git a/src/repo.rs b/src/repo.rs index e127f7e..945aab9 100644 --- a/src/repo.rs +++ b/src/repo.rs @@ -19,7 +19,7 @@ pub enum WorktreeRemoveFailureReason { } pub enum GitPushDefaultSetting { - Upstream + Upstream, } #[derive(Debug, PartialEq)] @@ -327,10 +327,17 @@ impl Repo { Ok(()) } - pub fn find_remote_branch(&self, remote_name: &str, branch_name: &str) -> Result { + pub fn find_remote_branch( + &self, + remote_name: &str, + branch_name: &str, + ) -> Result { Ok(Branch( self.0 - .find_branch(&format!("{}/{}", remote_name, branch_name), git2::BranchType::Remote) + .find_branch( + &format!("{}/{}", remote_name, branch_name), + git2::BranchType::Remote, + ) .map_err(convert_libgit2_error)?, )) } @@ -408,9 +415,12 @@ impl Repo { let mut config = self.config()?; config - .set_str(crate::GIT_CONFIG_PUSH_DEFAULT, match value { - GitPushDefaultSetting::Upstream => "upstream", - }) + .set_str( + crate::GIT_CONFIG_PUSH_DEFAULT, + match value { + GitPushDefaultSetting::Upstream => "upstream", + }, + ) .map_err(|error| { format!( "Could not set {}: {}", @@ -602,8 +612,11 @@ impl Repo { pub fn find_remote(&self, remote_name: &str) -> Result, String> { let remotes = self.0.remotes().map_err(convert_libgit2_error)?; - if !remotes.iter().any(|remote| remote.expect("Remote name is invalid utf-8") == remote_name) { - return Ok(None) + if !remotes + .iter() + .any(|remote| remote.expect("Remote name is invalid utf-8") == remote_name) + { + return Ok(None); } Ok(Some(RemoteHandle( @@ -760,7 +773,8 @@ impl Repo { let mut unmanaged_worktrees = Vec::new(); for entry in std::fs::read_dir(&directory).map_err(|error| error.to_string())? { let dirname = crate::path_as_string( - &entry.map_err(|error| error.to_string())? + &entry + .map_err(|error| error.to_string())? .path() .strip_prefix(&directory) // that unwrap() is safe as each entry is diff --git a/src/table.rs b/src/table.rs index 9b94c5e..8091725 100644 --- a/src/table.rs +++ b/src/table.rs @@ -274,7 +274,9 @@ fn add_worktree_status( Ok(()) } -pub fn show_single_repo_status(path: &Path) -> Result<(impl std::fmt::Display, Vec), String> { +pub fn show_single_repo_status( + path: &Path, +) -> Result<(impl std::fmt::Display, Vec), String> { let mut table = Table::new(); let mut warnings = Vec::new(); @@ -293,12 +295,18 @@ pub fn show_single_repo_status(path: &Path) -> Result<(impl std::fmt::Display, V let repo_name = match path.file_name() { None => { - warnings.push(format!("Cannot detect repo name for path {}. Are you working in /?", &path.display())); + warnings.push(format!( + "Cannot detect repo name for path {}. Are you working in /?", + &path.display() + )); String::from("unknown") } Some(file_name) => match file_name.to_str() { None => { - warnings.push(format!("Name of repo directory {} is not valid UTF-8", &path.display())); + warnings.push(format!( + "Name of repo directory {} is not valid UTF-8", + &path.display() + )); String::from("invalid") } Some(name) => name.to_string(), From 4a9f1bc2787525d666e1716a3606e0d0d04811b0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hannes=20K=C3=B6rber?= Date: Tue, 21 Dec 2021 16:15:12 +0100 Subject: [PATCH 17/68] Make new clippy happy --- src/grm/main.rs | 4 ++-- src/lib.rs | 1 + 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/src/grm/main.rs b/src/grm/main.rs index 046eb89..4aeaa28 100644 --- a/src/grm/main.rs +++ b/src/grm/main.rs @@ -140,8 +140,8 @@ fn main() { let split = branch.split_once('/'); if split.is_none() - || split.unwrap().0.len() == 0 - || split.unwrap().1.len() == 0 + || split.unwrap().0.is_empty() + || split.unwrap().1.is_empty() { print_error("Tracking branch needs to match the pattern /"); process::exit(1); diff --git a/src/lib.rs b/src/lib.rs index fded766..15af829 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -323,6 +323,7 @@ fn get_actual_git_directory(path: &Path, is_worktree: bool) -> PathBuf { /// /// The bool in the return value specifies whether there is a repository /// in root itself. +#[allow(clippy::type_complexity)] fn find_repos(root: &Path) -> Result, Vec, bool)>, String> { let mut repos: Vec = Vec::new(); let mut repo_in_root = false; From a3ccea9dcb0d10bed3e83f78645df6907da88d7a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hannes=20K=C3=B6rber?= Date: Tue, 21 Dec 2021 16:15:12 +0100 Subject: [PATCH 18/68] Justfile: Check cargo dep updates during "check" --- Justfile | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/Justfile b/Justfile index 1ab9c1d..8f3dc66 100644 --- a/Justfile +++ b/Justfile @@ -1,8 +1,11 @@ -check: test +check: check-cargo-lock test cargo check cargo fmt --check cargo clippy --no-deps -- -Dwarnings +check-cargo-lock: + cargo update --locked + lint-fix: cargo clippy --no-deps --fix From 1a1231b67251ffa53df1c436dc945ef11e1f03ca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hannes=20K=C3=B6rber?= Date: Tue, 21 Dec 2021 16:15:12 +0100 Subject: [PATCH 19/68] depcheck: Add functionality to disable autoupdate for packages --- depcheck/update-cargo-dependencies.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/depcheck/update-cargo-dependencies.py b/depcheck/update-cargo-dependencies.py index 02acb84..1e9b196 100755 --- a/depcheck/update-cargo-dependencies.py +++ b/depcheck/update-cargo-dependencies.py @@ -10,6 +10,10 @@ import tomlkit INDEX_DIR = "crates.io-index" +AUTOUPDATE_DISABLED = [ + "clap", +] + if os.path.exists(INDEX_DIR): subprocess.run( ["git", "pull", "--depth=1", "origin"], @@ -53,6 +57,13 @@ for tier in ["dependencies", "dev-dependencies"]: latest_version = version if latest_version != current_version: + if name in AUTOUPDATE_DISABLED: + print( + f"{name} {current_version}: There is a new version available " + f"({latest_version}, current {current_version}), but autoupdating " + f"is explictly disabled for {name}" + ) + continue update_necessary = True if latest_version < current_version: print( From e3563fcaa6c03f619d3f0cd3c23b2a6a46ef55b5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hannes=20K=C3=B6rber?= Date: Tue, 21 Dec 2021 16:15:12 +0100 Subject: [PATCH 20/68] depcheck: Add update for Cargo.lock --- depcheck/update-cargo-dependencies.py | 38 +++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/depcheck/update-cargo-dependencies.py b/depcheck/update-cargo-dependencies.py index 1e9b196..6e8adae 100755 --- a/depcheck/update-cargo-dependencies.py +++ b/depcheck/update-cargo-dependencies.py @@ -85,5 +85,43 @@ for tier in ["dependencies", "dev-dependencies"]: ) +# This updates the crates.io index, see https://github.com/rust-lang/cargo/issues/3377 +subprocess.run( + ["cargo", "search", "--limit", "0"], + check=True, + capture_output=False, # to get some git output +) + +# Note that we have to restart this lookup every time, as later packages can depend +# on former packages +while True: + with open("../Cargo.lock", "r") as f: + cargo_lock = tomlkit.parse(f.read()) + for package in cargo_lock['package']: + spec = f"{package['name']}:{package['version']}" + try: + cmd = subprocess.run( + ["cargo", "update", "-Z", "no-index-update", "--aggressive", "--package", spec], + check=True, + capture_output=True, + text=True, + ) + except subprocess.CalledProcessError as e: + print(e.stdout) + print(e.stderr) + raise + if len(cmd.stderr) != 0: + update_necessary = True + message = "Cargo.lock: {}".format(cmd.stderr.split("\n")[0].strip()) + print(message) + cmd = subprocess.run( + ["git", "commit", "--message", message, "../Cargo.lock"], + check=True, + capture_output=True + ) + break + else: + break + if update_necessary is False: print("Everything up to date") From d85c98c3ef0cf9a3a53d1c6a7efe0361f3743030 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hannes=20K=C3=B6rber?= Date: Tue, 21 Dec 2021 16:15:12 +0100 Subject: [PATCH 21/68] Cargo.lock: Updating git2 v0.13.24 -> v0.13.25 --- Cargo.lock | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 4b379a1..ebcd2d8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -203,9 +203,9 @@ dependencies = [ [[package]] name = "git2" -version = "0.13.24" +version = "0.13.25" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "845e007a28f1fcac035715988a234e8ec5458fd825b20a20c7dec74237ef341f" +checksum = "f29229cc1b24c0e6062f6e742aa3e256492a5323365e5ed3413599f8a5eff7d6" dependencies = [ "bitflags", "libc", @@ -293,9 +293,9 @@ checksum = "8521a1b57e76b1ec69af7599e75e38e7b7fad6610f037db8c79b127201b5d119" [[package]] name = "libgit2-sys" -version = "0.12.25+1.3.0" +version = "0.12.26+1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f68169ef08d6519b2fe133ecc637408d933c0174b23b80bb2f79828966fbaab" +checksum = "19e1c899248e606fbfe68dcb31d8b0176ebab833b103824af31bddf4b7457494" dependencies = [ "cc", "libc", @@ -595,18 +595,18 @@ checksum = "d29ab0c6d3fc0ee92fe66e2d99f700eab17a8d57d1c1d3b748380fb20baa78cd" [[package]] name = "serde" -version = "1.0.130" +version = "1.0.131" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f12d06de37cf59146fbdecab66aa99f9fe4f78722e3607577a5375d66bd0c913" +checksum = "b4ad69dfbd3e45369132cc64e6748c2d65cdfb001a2b1c232d128b4ad60561c1" dependencies = [ "serde_derive", ] [[package]] name = "serde_derive" -version = "1.0.130" +version = "1.0.131" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7bc1a1ab1961464eae040d96713baa5a724a8152c1222492465b54322ec508b" +checksum = "b710a83c4e0dff6a3d511946b95274ad9ca9e5d3ae497b63fda866ac955358d2" dependencies = [ "proc-macro2", "quote", From 7514d1fd3e912aa04ca97ef6973d58376bc57d54 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hannes=20K=C3=B6rber?= Date: Tue, 21 Dec 2021 16:15:12 +0100 Subject: [PATCH 22/68] Cargo.lock: Updating libc v0.2.108 -> v0.2.112 --- Cargo.lock | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index ebcd2d8..9e39494 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -287,9 +287,9 @@ checksum = "e2abad23fbc42b3700f2f279844dc832adb2b2eb069b2df918f455c4e18cc646" [[package]] name = "libc" -version = "0.2.108" +version = "0.2.112" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8521a1b57e76b1ec69af7599e75e38e7b7fad6610f037db8c79b127201b5d119" +checksum = "1b03d17f364a3a042d5e5d46b053bbbf82c92c9430c592dd4c064dc6ee997125" [[package]] name = "libgit2-sys" From 4c94ead06f6795b0622225f8b8b4e688e50e72ec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hannes=20K=C3=B6rber?= Date: Tue, 21 Dec 2021 16:15:12 +0100 Subject: [PATCH 23/68] Cargo.lock: Updating proc-macro2 v1.0.32 -> v1.0.34 --- Cargo.lock | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 9e39494..1e8c51a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -489,9 +489,9 @@ dependencies = [ [[package]] name = "proc-macro2" -version = "1.0.32" +version = "1.0.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba508cc11742c0dc5c1659771673afbab7a0efab23aa17e854cbab0837ed0b43" +checksum = "2f84e92c0f7c9d58328b85a78557813e4bd845130db68d7184635344399423b1" dependencies = [ "unicode-xid", ] @@ -684,9 +684,9 @@ dependencies = [ [[package]] name = "syn" -version = "1.0.81" +version = "1.0.82" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2afee18b8beb5a596ecb4a2dce128c719b4ba399d34126b9e4396e3f9860966" +checksum = "8daf5dd0bb60cbd4137b1b587d2fc0ae729bc07cf01cd70b36a1ed5ade3b9d59" dependencies = [ "proc-macro2", "quote", From 24badb9b64f62204ed66f2923d04dcc83a33a630 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hannes=20K=C3=B6rber?= Date: Tue, 21 Dec 2021 16:15:12 +0100 Subject: [PATCH 24/68] Cargo.lock: Updating signal-hook v0.3.10 -> v0.3.12 --- Cargo.lock | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 1e8c51a..c754a79 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -624,9 +624,9 @@ dependencies = [ [[package]] name = "signal-hook" -version = "0.3.10" +version = "0.3.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c98891d737e271a2954825ef19e46bd16bdb98e2746f2eec4f7a4ef7946efd1" +checksum = "c35dfd12afb7828318348b8c408383cf5071a086c1d4ab1c0f9840ec92dbb922" dependencies = [ "libc", "signal-hook-registry", From 3501b785c9b69bc67a436d614815fed1a5faf905 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hannes=20K=C3=B6rber?= Date: Tue, 21 Dec 2021 16:15:12 +0100 Subject: [PATCH 25/68] Cargo.lock: Updating once_cell v1.8.0 -> v1.9.0 --- Cargo.lock | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index c754a79..06048d3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -394,9 +394,9 @@ dependencies = [ [[package]] name = "once_cell" -version = "1.8.0" +version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "692fcb63b64b1758029e0a96ee63e049ce8c5948587f2f7208df04625e5f6b56" +checksum = "da32515d9f6e6e489d7bc9d84c71b060db7247dc035bbe44eac88cf87486d8d5" [[package]] name = "openssl-probe" From 66814876a81131ddf5b1375c38f638aa94e52d1c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hannes=20K=C3=B6rber?= Date: Tue, 21 Dec 2021 16:15:12 +0100 Subject: [PATCH 26/68] Cargo.lock: Updating openssl-sys v0.9.71 -> v0.9.72 --- Cargo.lock | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 06048d3..8828c52 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -406,9 +406,9 @@ checksum = "28988d872ab76095a6e6ac88d99b54fd267702734fd7ffe610ca27f533ddb95a" [[package]] name = "openssl-sys" -version = "0.9.71" +version = "0.9.72" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7df13d165e607909b363a4757a6f133f8a818a74e9d3a98d09c6128e15fa4c73" +checksum = "7e46109c383602735fa0a2e48dd2b7c892b048e1bf69e5c3b1d804b7d9c203cb" dependencies = [ "autocfg", "cc", @@ -459,9 +459,9 @@ checksum = "d4fd5641d01c8f18a23da7b6fe29298ff4b55afcccdf78973b24cf3175fee32e" [[package]] name = "pkg-config" -version = "0.3.22" +version = "0.3.24" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "12295df4f294471248581bc09bef3c38a5e46f1e36d6a37353621a0c6c357e1f" +checksum = "58893f751c9b0412871a09abd62ecd2a00298c6c83befa223ef98c52aef40cbe" [[package]] name = "proc-macro-error" From 1ffc522d51a8284b701dc511190cd4cc205029d2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hannes=20K=C3=B6rber?= Date: Wed, 22 Dec 2021 10:02:43 +0100 Subject: [PATCH 27/68] dependencies: Update serde to 1.0.132 --- Cargo.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Cargo.toml b/Cargo.toml index a1ee0d6..2fa74e9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -40,7 +40,7 @@ path = "src/grm/main.rs" version = "=0.5.8" [dependencies.serde] -version = "=1.0.131" +version = "=1.0.132" features = ["derive"] [dependencies.git2] From 1e941e02edb5ca36e36663941fb60b06812458b6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hannes=20K=C3=B6rber?= Date: Wed, 22 Dec 2021 10:02:44 +0100 Subject: [PATCH 28/68] Cargo.lock: Updating serde v1.0.131 -> v1.0.132 --- Cargo.lock | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 8828c52..f25cd60 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -595,18 +595,18 @@ checksum = "d29ab0c6d3fc0ee92fe66e2d99f700eab17a8d57d1c1d3b748380fb20baa78cd" [[package]] name = "serde" -version = "1.0.131" +version = "1.0.132" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b4ad69dfbd3e45369132cc64e6748c2d65cdfb001a2b1c232d128b4ad60561c1" +checksum = "8b9875c23cf305cd1fd7eb77234cbb705f21ea6a72c637a5c6db5fe4b8e7f008" dependencies = [ "serde_derive", ] [[package]] name = "serde_derive" -version = "1.0.131" +version = "1.0.132" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b710a83c4e0dff6a3d511946b95274ad9ca9e5d3ae497b63fda866ac955358d2" +checksum = "ecc0db5cb2556c0e558887d9bbdcf6ac4471e83ff66cf696e5419024d1606276" dependencies = [ "proc-macro2", "quote", From 70eac10eaa830c28aae566a4d8eb41c47bd5dbce Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hannes=20K=C3=B6rber?= Date: Wed, 22 Dec 2021 10:02:45 +0100 Subject: [PATCH 29/68] Cargo.lock: Updating signal-hook v0.3.12 -> v0.3.13 --- Cargo.lock | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index f25cd60..4944217 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -624,9 +624,9 @@ dependencies = [ [[package]] name = "signal-hook" -version = "0.3.12" +version = "0.3.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c35dfd12afb7828318348b8c408383cf5071a086c1d4ab1c0f9840ec92dbb922" +checksum = "647c97df271007dcea485bb74ffdb57f2e683f1306c854f468a0c244badabf2d" dependencies = [ "libc", "signal-hook-registry", From 27586b5ff05b6bbd65bf24a3ae322bdc1faf0377 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hannes=20K=C3=B6rber?= Date: Thu, 23 Dec 2021 18:33:14 +0100 Subject: [PATCH 30/68] Add functionality for persistent branches --- docs/src/worktrees.md | 34 +++++ e2e_tests/test_repos_find.py | 1 + .../test_worktree_config_presistent_branch.py | 143 ++++++++++++++++++ src/grm/main.rs | 15 +- src/repo.rs | 119 ++++++++++++--- src/table.rs | 15 +- 6 files changed, 297 insertions(+), 30 deletions(-) create mode 100644 e2e_tests/test_worktree_config_presistent_branch.py diff --git a/docs/src/worktrees.md b/docs/src/worktrees.md index c808e7b..8feb16f 100644 --- a/docs/src/worktrees.md +++ b/docs/src/worktrees.md @@ -71,6 +71,10 @@ Now, when you run a `grm sync`, you'll notice that the directory of the reposito is empty! Well, not totally, there is a hidden directory called `.git-main-working-tree`. This is where the repository actually "lives" (it's a bare checkout). +Note that there are few specific things you can configure for a certain +workspace. This is all done in an optional `grm.toml` file right in the root +of the worktree. More on that later. + ### Creating a new worktree To actually work, you'll first have to create a new worktree checkout. All @@ -195,6 +199,36 @@ $ grm wt clean Note that this will not delete the default branch of the repository. It can of course still be delete with `grm wt delete` if neccessary. +### Persistent branches + +You most likely have a few branches that are "special", that you don't want to +clean up and that are the usual target for feature branches to merge into. GRM +calls them "persistent branches" and treats them a bit differently: + +* Their worktrees will never be deleted by `grm wt clean` +* If the branches in other worktrees are merged into them, they will be cleaned + up, even though they may not be in line with their upstream. Same goes for + `grm wt delete`, which will not require a `--force` flag. Note that of + course, actual changes in the worktree will still block an automatic cleanup! +* As soon as you enable persistent branches, non-persistent branches will only + ever cleaned up when merged into a persistent branch. + +To elaborate: This is mostly relevant for a feature-branch workflow. Whenever a +feature branch is merged, it can usually be thrown away. As merging is usually +done on some remote code management platform (GitHub, GitLab, ...), this means +that you usually keep a branch around until it is merged into one of the "main" +branches (`master`, `main`, `develop`, ...) + +Enable persistent branches by setting the following in the `grm.toml` in the +worktree root: + +```toml +persistent_branches = [ + "master", + "develop", +] +``` + ### Converting an existing repository It is possible to convert an existing directory to a worktree setup, using `grm diff --git a/e2e_tests/test_repos_find.py b/e2e_tests/test_repos_find.py index 82113ba..2939e95 100644 --- a/e2e_tests/test_repos_find.py +++ b/e2e_tests/test_repos_find.py @@ -159,6 +159,7 @@ def test_repos_find_in_root(): assert set(origin.keys()) == {"name", "type", "url"} assert someremote["type"] == "file" + def test_repos_find_with_invalid_repo(): with tempfile.TemporaryDirectory() as tmpdir: shell( diff --git a/e2e_tests/test_worktree_config_presistent_branch.py b/e2e_tests/test_worktree_config_presistent_branch.py new file mode 100644 index 0000000..c8f3f6a --- /dev/null +++ b/e2e_tests/test_worktree_config_presistent_branch.py @@ -0,0 +1,143 @@ +#!/usr/bin/env python3 + +import os.path + +from helpers import * + + +def test_worktree_never_clean_persistent_branches(): + with TempGitRepositoryWorktree() as base_dir: + with open(os.path.join(base_dir, "grm.toml"), "w") as f: + f.write( + """ + persistent_branches = [ + "mybranch", + ] + """ + ) + + cmd = grm(["wt", "add", "mybranch", "--track", "origin/master"], cwd=base_dir) + assert cmd.returncode == 0 + + before = checksum_directory(f"{base_dir}/mybranch") + + cmd = grm(["wt", "clean"], cwd=base_dir) + assert cmd.returncode == 0 + + assert "mybranch" in os.listdir(base_dir) + repo = git.Repo(os.path.join(base_dir, "mybranch")) + assert str(repo.active_branch) == "mybranch" + + after = checksum_directory(f"{base_dir}/mybranch") + assert before == after + + +def test_worktree_clean_branch_merged_into_persistent(): + with TempGitRepositoryWorktree() as base_dir: + with open(os.path.join(base_dir, "grm.toml"), "w") as f: + f.write( + """ + persistent_branches = [ + "master", + ] + """ + ) + + cmd = grm(["wt", "add", "test", "--track", "origin/test"], cwd=base_dir) + assert cmd.returncode == 0 + + shell( + f""" + cd {base_dir}/test + touch change1 + git add change1 + git commit -m "commit1" + """ + ) + + cmd = grm(["wt", "add", "master"], cwd=base_dir) + assert cmd.returncode == 0 + + shell( + f""" + cd {base_dir}/master + git merge --no-ff test + """ + ) + + cmd = grm(["wt", "clean"], cwd=base_dir) + assert cmd.returncode == 0 + + assert "test" not in os.listdir(base_dir) + + +def test_worktree_no_clean_unmerged_branch(): + with TempGitRepositoryWorktree() as base_dir: + with open(os.path.join(base_dir, "grm.toml"), "w") as f: + f.write( + """ + persistent_branches = [ + "master", + ] + """ + ) + + cmd = grm(["wt", "add", "test", "--track", "origin/test"], cwd=base_dir) + assert cmd.returncode == 0 + + shell( + f""" + cd {base_dir}/test + touch change1 + git add change1 + git commit -m "commit1" + git push origin test + """ + ) + + cmd = grm(["wt", "add", "master"], cwd=base_dir) + assert cmd.returncode == 0 + + cmd = grm(["wt", "clean"], cwd=base_dir) + assert cmd.returncode == 0 + + assert "test" in os.listdir(base_dir) + + +def test_worktree_delete_branch_merged_into_persistent(): + with TempGitRepositoryWorktree() as base_dir: + with open(os.path.join(base_dir, "grm.toml"), "w") as f: + f.write( + """ + persistent_branches = [ + "master", + ] + """ + ) + + cmd = grm(["wt", "add", "test", "--track", "origin/test"], cwd=base_dir) + assert cmd.returncode == 0 + + shell( + f""" + cd {base_dir}/test + touch change1 + git add change1 + git commit -m "commit1" + """ + ) + + cmd = grm(["wt", "add", "master"], cwd=base_dir) + assert cmd.returncode == 0 + + shell( + f""" + cd {base_dir}/master + git merge --no-ff test + """ + ) + + cmd = grm(["wt", "delete", "test"], cwd=base_dir) + assert cmd.returncode == 0 + + assert "test" not in os.listdir(base_dir) diff --git a/src/grm/main.rs b/src/grm/main.rs index 4aeaa28..b6dad4d 100644 --- a/src/grm/main.rs +++ b/src/grm/main.rs @@ -3,6 +3,7 @@ use std::process; mod cmd; +use grm::repo; use grm::config; use grm::output::*; @@ -171,12 +172,21 @@ fn main() { } cmd::WorktreeAction::Delete(action_args) => { let worktree_dir = cwd.join(&action_args.name); + + let worktree_config = match repo::read_worktree_root_config(&cwd) { + Ok(config) => config, + Err(error) => { + print_error(&format!("Error getting worktree configuration: {}", error)); + process::exit(1); + } + }; + let repo = grm::Repo::open(&cwd, true).unwrap_or_else(|error| { print_error(&format!("Error opening repository: {}", error)); process::exit(1); }); - match repo.remove_worktree(&action_args.name, &worktree_dir, action_args.force) + match repo.remove_worktree(&action_args.name, &worktree_dir, action_args.force, &worktree_config) { Ok(_) => print_success(&format!("Worktree {} deleted", &action_args.name)), Err(error) => { @@ -191,6 +201,9 @@ fn main() { changes )); } + grm::WorktreeRemoveFailureReason::NotMerged(message) => { + print_warning(&message); + } } process::exit(1); } diff --git a/src/repo.rs b/src/repo.rs index 945aab9..23eeb4a 100644 --- a/src/repo.rs +++ b/src/repo.rs @@ -5,6 +5,8 @@ use git2::{Cred, RemoteCallbacks, Repository}; use crate::output::*; +const WORKTREE_CONFIG_FILE_NAME: &str = "grm.toml"; + #[derive(Debug, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "snake_case")] pub enum RemoteType { @@ -16,6 +18,7 @@ pub enum RemoteType { pub enum WorktreeRemoveFailureReason { Changes(String), Error(String), + NotMerged(String), } pub enum GitPushDefaultSetting { @@ -39,6 +42,37 @@ impl RepoError { } } +#[derive(Debug, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct WorktreeRootConfig { + pub persistent_branches: Option>, +} + +pub fn read_worktree_root_config(worktree_root: &Path) -> Result, String> { + let path = worktree_root.join(WORKTREE_CONFIG_FILE_NAME); + let content = match std::fs::read_to_string(&path) { + Ok(s) => s, + Err(e) => { + match e.kind() { + std::io::ErrorKind::NotFound => return Ok(None), + _ => return Err(format!("Error reading configuration file \"{}\": {}", path.display(), e)), + } + } + }; + + let config: WorktreeRootConfig = match toml::from_str(&content) { + Ok(c) => c, + Err(e) => { + return Err(format!( + "Error parsing configuration file \"{}\": {}", + path.display(), e + )) + } + }; + + Ok(Some(config)) +} + impl std::error::Error for RepoError {} impl std::fmt::Display for RepoError { @@ -237,10 +271,9 @@ impl Repo { // name exists let branch = self .find_local_branch( - &head + head .shorthand() - .expect("Branch name is not valid utf-8") - .to_string(), + .expect("Branch name is not valid utf-8"), ) .unwrap(); Ok(branch) @@ -642,6 +675,7 @@ impl Repo { name: &str, worktree_dir: &Path, force: bool, + worktree_config: &Option, ) -> Result<(), WorktreeRemoveFailureReason> { if !worktree_dir.exists() { return Err(WorktreeRemoveFailureReason::Error(format!( @@ -684,25 +718,55 @@ impl Repo { ))); } - match branch.upstream() { - Ok(remote_branch) => { - let (ahead, behind) = worktree_repo - .graph_ahead_behind(&branch, &remote_branch) - .unwrap(); + let mut is_merged_into_persistent_branch = false; + let mut has_persistent_branches = false; + if let Some(config) = worktree_config { + if let Some(branches) = &config.persistent_branches { + has_persistent_branches = true; + for persistent_branch in branches { + let persistent_branch = worktree_repo + .find_local_branch(persistent_branch) + .map_err(WorktreeRemoveFailureReason::Error)?; - if (ahead, behind) != (0, 0) { + let (ahead, _behind) = worktree_repo + .graph_ahead_behind(&branch, &persistent_branch) + .unwrap(); + + if ahead == 0 { + is_merged_into_persistent_branch = true; + } + } + } + } + + if has_persistent_branches && !is_merged_into_persistent_branch { + return Err(WorktreeRemoveFailureReason::NotMerged(format!( + "Branch {} is not merged into any persistent branches", + name + ))); + } + + if !has_persistent_branches { + match branch.upstream() { + Ok(remote_branch) => { + let (ahead, behind) = worktree_repo + .graph_ahead_behind(&branch, &remote_branch) + .unwrap(); + + if (ahead, behind) != (0, 0) { + return Err(WorktreeRemoveFailureReason::Changes(format!( + "Branch {} is not in line with remote branch", + name + ))); + } + } + Err(_) => { return Err(WorktreeRemoveFailureReason::Changes(format!( - "Branch {} is not in line with remote branch", + "No remote tracking branch for branch {} found", name ))); } } - Err(_) => { - return Err(WorktreeRemoveFailureReason::Changes(format!( - "No remote tracking branch for branch {} found", - name - ))); - } } } @@ -737,13 +801,22 @@ impl Repo { .name() .map_err(|error| format!("Failed getting default branch name: {}", error))?; + let config = read_worktree_root_config(directory)?; + for worktree in worktrees .iter() .filter(|worktree| *worktree != &default_branch_name) + .filter(|worktree| match &config { + None => true, + Some(config) => match &config.persistent_branches { + None => true, + Some(branches) => !branches.contains(worktree), + }, + }) { let repo_dir = &directory.join(&worktree); if repo_dir.exists() { - match self.remove_worktree(worktree, repo_dir, false) { + match self.remove_worktree(worktree, repo_dir, false, &config) { Ok(_) => print_success(&format!("Worktree {} deleted", &worktree)), Err(error) => match error { WorktreeRemoveFailureReason::Changes(changes) => { @@ -753,6 +826,10 @@ impl Repo { )); continue; } + WorktreeRemoveFailureReason::NotMerged(message) => { + warnings.push(message); + continue; + } WorktreeRemoveFailureReason::Error(error) => { return Err(error); } @@ -773,14 +850,13 @@ impl Repo { let mut unmanaged_worktrees = Vec::new(); for entry in std::fs::read_dir(&directory).map_err(|error| error.to_string())? { let dirname = crate::path_as_string( - &entry + entry .map_err(|error| error.to_string())? .path() .strip_prefix(&directory) // that unwrap() is safe as each entry is // guaranteed to be a subentry of &directory - .unwrap() - .to_path_buf(), + .unwrap(), ); let default_branch = self @@ -794,6 +870,9 @@ impl Repo { if dirname == crate::GIT_MAIN_WORKTREE_DIRECTORY { continue; } + if dirname == WORKTREE_CONFIG_FILE_NAME { + continue; + } if dirname == default_branch_name { continue; } diff --git a/src/table.rs b/src/table.rs index 8091725..f48ce5d 100644 --- a/src/table.rs +++ b/src/table.rs @@ -46,7 +46,7 @@ fn add_repo_status( } None => String::from("\u{2714}"), }, - &repo_status + repo_status .branches .iter() .map(|(branch_name, remote_branch)| { @@ -73,8 +73,7 @@ fn add_repo_status( ) }) .collect::() - .trim() - .to_string(), + .trim(), &match is_worktree { true => String::from(""), false => match repo_status.head { @@ -82,13 +81,12 @@ fn add_repo_status( None => String::from("Empty"), }, }, - &repo_status + repo_status .remotes .iter() .map(|r| format!("{}\n", r)) .collect::() - .trim() - .to_string(), + .trim(), ]); Ok(()) @@ -127,14 +125,13 @@ pub fn get_worktree_status_table( } for entry in std::fs::read_dir(&directory).map_err(|error| error.to_string())? { let dirname = crate::path_as_string( - &entry + entry .map_err(|error| error.to_string())? .path() .strip_prefix(&directory) // this unwrap is safe, as we can be sure that each subentry of // &directory also has the prefix &dir - .unwrap() - .to_path_buf(), + .unwrap(), ); if dirname == crate::GIT_MAIN_WORKTREE_DIRECTORY { continue; From b183590096e0cf189e5fa636b32f02d1d858bb7c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hannes=20K=C3=B6rber?= Date: Thu, 23 Dec 2021 18:33:14 +0100 Subject: [PATCH 31/68] Add default tracking configuration --- docs/src/worktrees.md | 30 ++++ e2e_tests/test_worktrees.py | 274 +++++++++++++++++++++++++++++++++--- src/grm/cmd.rs | 3 + src/grm/main.rs | 1 + src/lib.rs | 112 +++++++++++---- src/repo.rs | 10 ++ 6 files changed, 382 insertions(+), 48 deletions(-) diff --git a/docs/src/worktrees.md b/docs/src/worktrees.md index 8feb16f..7dd4799 100644 --- a/docs/src/worktrees.md +++ b/docs/src/worktrees.md @@ -134,6 +134,36 @@ The behaviour of `--track` differs depending on the existence of the remote bran new remote tracking branch, using the default branch (either `main` or `master`) as the base +Often, you'll have a workflow that uses tracking branches by default. It would +be quite tedious to add `--track` every single time. Luckily, the `grm.toml` file +supports defaults for the tracking behaviour. See this for an example: + +```toml +[track] +default = true +default_remote = "origin" +``` + +This will set up a tracking branch on `origin` that has the same name as the local +branch. + +Sometimes, you might want to have a certain prefix for all your tracking branches. +Maybe to prevent collissions with other contributors. You can simply set +`default_remote_prefix` in `grm.toml`: + +```toml +[track] +default = true +default_remote = "origin" +default_remote_prefix = "myname" +``` + +When using branch `my-feature-branch`, the remote tracking branch would be +`origin/myname/my-feature-branch` in this case. + +Note that `--track` overrides any configuration in `grm.toml`. If you want to +disable tracking, use `--no-track`. + ### Showing the status of your worktrees There is a handy little command that will show your an overview over all worktrees diff --git a/e2e_tests/test_worktrees.py b/e2e_tests/test_worktrees.py index 4a63cd0..7539641 100644 --- a/e2e_tests/test_worktrees.py +++ b/e2e_tests/test_worktrees.py @@ -4,38 +4,266 @@ from helpers import * import git +import os.path + def test_worktree_add_simple(): - with TempGitRepositoryWorktree() as base_dir: - cmd = grm(["wt", "add", "test"], cwd=base_dir) - assert cmd.returncode == 0 + for remote_branch_already_exists in (True, False): + for has_config in (True, False): + for has_default in (True, False): + for has_prefix in (True, False): + with TempGitRepositoryWorktree() as base_dir: + if has_config: + with open(os.path.join(base_dir, "grm.toml"), "w") as f: + f.write( + f""" + [track] + default = {str(has_default).lower()} + default_remote = "origin" + """ + ) + if has_prefix: + f.write( + """ + default_remote_prefix = "myprefix" + """ + ) - files = os.listdir(base_dir) - assert len(files) == 2 - assert set(files) == {".git-main-working-tree", "test"} + if remote_branch_already_exists: + shell( + f""" + cd {base_dir} + git --git-dir ./.git-main-working-tree worktree add tmp + ( + cd tmp + touch change + git add change + git commit -m commit + git push origin HEAD:test + #git reset --hard 'HEAD@{1}' + git branch -va + ) + git --git-dir ./.git-main-working-tree worktree remove tmp + """ + ) + cmd = grm(["wt", "add", "test"], cwd=base_dir) + assert cmd.returncode == 0 - repo = git.Repo(os.path.join(base_dir, "test")) - assert not repo.bare - assert not repo.is_dirty() - assert str(repo.active_branch) == "test" - assert repo.active_branch.tracking_branch() is None + files = os.listdir(base_dir) + if has_config is True: + assert len(files) == 3 + assert set(files) == { + ".git-main-working-tree", + "grm.toml", + "test", + } + else: + assert len(files) == 2 + assert set(files) == {".git-main-working-tree", "test"} + + repo = git.Repo(os.path.join(base_dir, "test")) + assert not repo.bare + assert not repo.is_dirty() + if has_config and has_default: + if has_prefix and not remote_branch_already_exists: + assert ( + str(repo.active_branch.tracking_branch()) + == "origin/myprefix/test" + ) + else: + assert ( + str(repo.active_branch.tracking_branch()) + == "origin/test" + ) + else: + assert repo.active_branch.tracking_branch() is None def test_worktree_add_with_tracking(): - with TempGitRepositoryWorktree() as base_dir: - cmd = grm(["wt", "add", "test", "--track", "origin/test"], cwd=base_dir) - print(cmd.stderr) - assert cmd.returncode == 0 + for remote_branch_already_exists in (True, False): + for has_config in (True, False): + for has_default in (True, False): + for has_prefix in (True, False): + with TempGitRepositoryWorktree() as base_dir: + if has_config: + with open(os.path.join(base_dir, "grm.toml"), "w") as f: + f.write( + f""" + [track] + default = {str(has_default).lower()} + default_remote = "origin" + """ + ) + if has_prefix: + f.write( + """ + default_remote_prefix = "myprefix" + """ + ) - files = os.listdir(base_dir) - assert len(files) == 2 - assert set(files) == {".git-main-working-tree", "test"} + if remote_branch_already_exists: + shell( + f""" + cd {base_dir} + git --git-dir ./.git-main-working-tree worktree add tmp + ( + cd tmp + touch change + git add change + git commit -m commit + git push origin HEAD:test + #git reset --hard 'HEAD@{1}' + git branch -va + ) + git --git-dir ./.git-main-working-tree worktree remove tmp + """ + ) + cmd = grm( + ["wt", "add", "test", "--track", "origin/test"], + cwd=base_dir, + ) + print(cmd.stderr) + assert cmd.returncode == 0 - repo = git.Repo(os.path.join(base_dir, "test")) - assert not repo.bare - assert not repo.is_dirty() - assert str(repo.active_branch) == "test" - assert str(repo.active_branch.tracking_branch()) == "origin/test" + files = os.listdir(base_dir) + if has_config is True: + assert len(files) == 3 + assert set(files) == { + ".git-main-working-tree", + "grm.toml", + "test", + } + else: + assert len(files) == 2 + assert set(files) == {".git-main-working-tree", "test"} + + repo = git.Repo(os.path.join(base_dir, "test")) + assert not repo.bare + assert not repo.is_dirty() + assert str(repo.active_branch) == "test" + assert ( + str(repo.active_branch.tracking_branch()) == "origin/test" + ) + + +def test_worktree_add_with_explicit_no_tracking(): + for has_config in (True, False): + for has_default in (True, False): + for has_prefix in (True, False): + for track in (True, False): + with TempGitRepositoryWorktree() as base_dir: + if has_config: + with open(os.path.join(base_dir, "grm.toml"), "w") as f: + f.write( + f""" + [track] + default = {str(has_default).lower()} + default_remote = "origin" + """ + ) + if has_prefix: + f.write( + """ + default_remote_prefix = "myprefix" + """ + ) + if track is True: + cmd = grm( + [ + "wt", + "add", + "test", + "--track", + "origin/test", + "--no-track", + ], + cwd=base_dir, + ) + else: + cmd = grm(["wt", "add", "test", "--no-track"], cwd=base_dir) + print(cmd.stderr) + assert cmd.returncode == 0 + + files = os.listdir(base_dir) + if has_config is True: + assert len(files) == 3 + assert set(files) == { + ".git-main-working-tree", + "grm.toml", + "test", + } + else: + assert len(files) == 2 + assert set(files) == {".git-main-working-tree", "test"} + + repo = git.Repo(os.path.join(base_dir, "test")) + assert not repo.bare + assert not repo.is_dirty() + assert str(repo.active_branch) == "test" + assert repo.active_branch.tracking_branch() is None + + +def test_worktree_add_with_config(): + for remote_branch_already_exists in (True, False): + for has_default in (True, False): + for has_prefix in (True, False): + with TempGitRepositoryWorktree() as base_dir: + with open(os.path.join(base_dir, "grm.toml"), "w") as f: + f.write( + f""" + [track] + default = {str(has_default).lower()} + default_remote = "origin" + """ + ) + if has_prefix: + f.write( + """ + default_remote_prefix = "myprefix" + """ + ) + if remote_branch_already_exists: + shell( + f""" + cd {base_dir} + git --git-dir ./.git-main-working-tree worktree add tmp + ( + cd tmp + touch change + git add change + git commit -m commit + git push origin HEAD:test + #git reset --hard 'HEAD@{1}' + git branch -va + ) + git --git-dir ./.git-main-working-tree worktree remove tmp + """ + ) + cmd = grm(["wt", "add", "test"], cwd=base_dir) + print(cmd.stderr) + assert cmd.returncode == 0 + + files = os.listdir(base_dir) + assert len(files) == 3 + assert set(files) == {".git-main-working-tree", "grm.toml", "test"} + + repo = git.Repo(os.path.join(base_dir, "test")) + assert not repo.bare + assert not repo.is_dirty() + assert str(repo.active_branch) == "test" + if has_default: + if has_prefix and not remote_branch_already_exists: + assert ( + str(repo.active_branch.tracking_branch()) + == "origin/myprefix/test" + ) + else: + assert ( + str(repo.active_branch.tracking_branch()) + == "origin/test" + ) + else: + assert repo.active_branch.tracking_branch() is None def test_worktree_delete(): diff --git a/src/grm/cmd.rs b/src/grm/cmd.rs index 6cf4a18..4532427 100644 --- a/src/grm/cmd.rs +++ b/src/grm/cmd.rs @@ -102,6 +102,9 @@ pub struct WorktreeAddArgs { pub branch_namespace: Option, #[clap(short = 't', long = "track", about = "Remote branch to track")] pub track: Option, + + #[clap(long = "--no-track", about = "Disable tracking")] + pub no_track: bool, } #[derive(Parser)] pub struct WorktreeDeleteArgs { diff --git a/src/grm/main.rs b/src/grm/main.rs index b6dad4d..8c2e8cd 100644 --- a/src/grm/main.rs +++ b/src/grm/main.rs @@ -162,6 +162,7 @@ fn main() { &action_args.name, action_args.branch_namespace.as_deref(), track, + action_args.no_track, ) { Ok(_) => print_success(&format!("Worktree {} created", &action_args.name)), Err(error) => { diff --git a/src/lib.rs b/src/lib.rs index 15af829..fa869f0 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -463,6 +463,7 @@ pub fn add_worktree( name: &str, branch_namespace: Option<&str>, track: Option<(&str, &str)>, + no_track: bool, ) -> Result<(), String> { let repo = Repo::open(directory, true).map_err(|error| match error.kind { RepoErrorKind::NotFound => { @@ -471,6 +472,8 @@ pub fn add_worktree( _ => format!("Error opening repo: {}", error), })?; + let config = repo::read_worktree_root_config(directory)?; + if repo.find_worktree(name).is_ok() { return Err(format!("Worktree {} already exists", &name)); } @@ -482,42 +485,101 @@ pub fn add_worktree( let mut remote_branch_exists = false; - let checkout_commit = match track { - Some((remote_name, remote_branch_name)) => { - let remote_branch = repo.find_remote_branch(remote_name, remote_branch_name); - match remote_branch { - Ok(branch) => { - remote_branch_exists = true; - branch.to_commit()? - } - Err(_) => { - remote_branch_exists = false; - repo.default_branch()?.to_commit()? + let default_checkout = || { + repo.default_branch()?.to_commit() + }; + + let checkout_commit; + if no_track { + checkout_commit = default_checkout()?; + } else { + match track { + Some((remote_name, remote_branch_name)) => { + let remote_branch = repo.find_remote_branch(remote_name, remote_branch_name); + match remote_branch { + Ok(branch) => { + remote_branch_exists = true; + checkout_commit = branch.to_commit()?; + } + Err(_) => { + remote_branch_exists = false; + checkout_commit = default_checkout()?; + } } } - } - None => repo.default_branch()?.to_commit()?, - }; + None => { + match &config { + None => checkout_commit = default_checkout()?, + Some(config) => { + match &config.track { + None => checkout_commit = default_checkout()?, + Some(track_config) => { + if track_config.default { + let remote_branch = repo.find_remote_branch(&track_config.default_remote, name); + match remote_branch { + Ok(branch) => { + remote_branch_exists = true; + checkout_commit = branch.to_commit()?; + } + Err(_) => { + checkout_commit = default_checkout()?; + } + } + } else { + checkout_commit = default_checkout()?; + } + } + } + } + } + } + }; + } let mut target_branch = match repo.find_local_branch(&branch_name) { Ok(branchref) => branchref, Err(_) => repo.create_branch(&branch_name, &checkout_commit)?, }; - if let Some((remote_name, remote_branch_name)) = track { - if remote_branch_exists { - target_branch.set_upstream(remote_name, remote_branch_name)?; - } else { - let mut remote = repo - .find_remote(remote_name) - .map_err(|error| format!("Error getting remote {}: {}", remote_name, error))? - .ok_or_else(|| format!("Remote {} not found", remote_name))?; + if !no_track { + if let Some((remote_name, remote_branch_name)) = track { + if remote_branch_exists { + target_branch.set_upstream(remote_name, remote_branch_name)?; + } else { + let mut remote = repo + .find_remote(remote_name) + .map_err(|error| format!("Error getting remote {}: {}", remote_name, error))? + .ok_or_else(|| format!("Remote {} not found", remote_name))?; - remote.push(&target_branch.name()?, remote_branch_name, &repo)?; + remote.push(&target_branch.name()?, remote_branch_name, &repo)?; - target_branch.set_upstream(remote_name, remote_branch_name)?; + target_branch.set_upstream(remote_name, remote_branch_name)?; + } + } else if let Some(config) = config { + if let Some(track_config) = config.track { + if track_config.default { + let remote_name = track_config.default_remote; + if remote_branch_exists { + target_branch.set_upstream(&remote_name, name)?; + } else { + let remote_branch_name = match track_config.default_remote_prefix { + Some(prefix) => format!("{}{}{}", &prefix, BRANCH_NAMESPACE_SEPARATOR, &name), + None => name.to_string(), + }; + + let mut remote = repo + .find_remote(&remote_name) + .map_err(|error| format!("Error getting remote {}: {}", remote_name, error))? + .ok_or_else(|| format!("Remote {} not found", remote_name))?; + + remote.push(&target_branch.name()?, &remote_branch_name, &repo)?; + + target_branch.set_upstream(&remote_name, &remote_branch_name)?; + } + } + } } - }; + } repo.new_worktree(name, &directory.join(&name), &target_branch)?; diff --git a/src/repo.rs b/src/repo.rs index 23eeb4a..2f7f95b 100644 --- a/src/repo.rs +++ b/src/repo.rs @@ -42,10 +42,20 @@ impl RepoError { } } +#[derive(Debug, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct TrackingConfig { + pub default: bool, + pub default_remote: String, + pub default_remote_prefix: Option, +} + #[derive(Debug, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct WorktreeRootConfig { pub persistent_branches: Option>, + + pub track: Option, } pub fn read_worktree_root_config(worktree_root: &Path) -> Result, String> { From fcc22791e57d691e24545938318c10d982984b0f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hannes=20K=C3=B6rber?= Date: Thu, 23 Dec 2021 18:33:14 +0100 Subject: [PATCH 32/68] Refuse to push against non-pushable remotes (e.g. HTTPS for now) --- src/lib.rs | 16 +++++++++++++--- src/repo.rs | 9 +++++++++ 2 files changed, 22 insertions(+), 3 deletions(-) diff --git a/src/lib.rs b/src/lib.rs index fa869f0..b6151af 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -66,7 +66,7 @@ pub fn path_as_string(path: &Path) -> String { path.to_path_buf().into_os_string().into_string().unwrap() } -fn env_home() -> PathBuf { +pub fn env_home() -> PathBuf { match std::env::var("HOME") { Ok(path) => Path::new(&path).to_path_buf(), Err(e) => { @@ -541,6 +541,13 @@ pub fn add_worktree( Err(_) => repo.create_branch(&branch_name, &checkout_commit)?, }; + fn push(remote: &mut repo::RemoteHandle, branch_name: &str, remote_branch_name: &str, repo: &repo::Repo) -> Result<(), String>{ + if !remote.is_pushable()? { + return Err(format!("Cannot push to non-pushable remote {}", remote.url())); + } + remote.push(branch_name, remote_branch_name, repo) + } + if !no_track { if let Some((remote_name, remote_branch_name)) = track { if remote_branch_exists { @@ -551,7 +558,7 @@ pub fn add_worktree( .map_err(|error| format!("Error getting remote {}: {}", remote_name, error))? .ok_or_else(|| format!("Remote {} not found", remote_name))?; - remote.push(&target_branch.name()?, remote_branch_name, &repo)?; + push(&mut remote, &target_branch.name()?, remote_branch_name, &repo)?; target_branch.set_upstream(remote_name, remote_branch_name)?; } @@ -572,7 +579,10 @@ pub fn add_worktree( .map_err(|error| format!("Error getting remote {}: {}", remote_name, error))? .ok_or_else(|| format!("Remote {} not found", remote_name))?; - remote.push(&target_branch.name()?, &remote_branch_name, &repo)?; + if !remote.is_pushable()? { + return Err(format!("Cannot push to non-pushable remote {}", remote.url())); + } + push(&mut remote, &target_branch.name()?, &remote_branch_name, &repo)?; target_branch.set_upstream(&remote_name, &remote_branch_name)?; } diff --git a/src/repo.rs b/src/repo.rs index 2f7f95b..682ae9d 100644 --- a/src/repo.rs +++ b/src/repo.rs @@ -980,12 +980,21 @@ impl RemoteHandle<'_> { .to_string() } + pub fn is_pushable(&self) -> Result { + let remote_type = detect_remote_type(self.0.url().expect("Remote name is not valid utf-8")).ok_or_else(|| String::from("Could not detect remote type"))?; + Ok(matches!(remote_type, RemoteType::Ssh | RemoteType::File)) + } + pub fn push( &mut self, local_branch_name: &str, remote_branch_name: &str, _repo: &Repo, ) -> Result<(), String> { + if !self.is_pushable()? { + return Err(String::from("Trying to push to a non-pushable remote")); + } + let mut callbacks = git2::RemoteCallbacks::new(); callbacks.push_update_reference(|_, status| { if let Some(message) = status { From 552b3a6aadeddfda0ae772e7367c1b1e1030986a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hannes=20K=C3=B6rber?= Date: Thu, 23 Dec 2021 18:33:14 +0100 Subject: [PATCH 33/68] SSH: Fall back to ~/.ssh/id_rsa when no agent available --- src/repo.rs | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/src/repo.rs b/src/repo.rs index 682ae9d..cba2011 100644 --- a/src/repo.rs +++ b/src/repo.rs @@ -1007,7 +1007,16 @@ impl RemoteHandle<'_> { Ok(()) }); callbacks.credentials(|_url, username_from_url, _allowed_types| { - git2::Cred::ssh_key_from_agent(username_from_url.unwrap()) + let username = match username_from_url { + Some(username) => username, + None => panic!("Could not get username. This is a bug"), + }; + git2::Cred::ssh_key_from_agent(username).or_else(|_| git2::Cred::ssh_key( + username, + None, + &crate::env_home().join(".ssh/id_rsa"), + None, + )) }); let mut push_options = git2::PushOptions::new(); From 02e9de0cbddd38d4e3d0b7881567dee3a540bff9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hannes=20K=C3=B6rber?= Date: Thu, 23 Dec 2021 18:33:14 +0100 Subject: [PATCH 34/68] Proper formatting --- src/grm/main.rs | 15 +++++++--- src/lib.rs | 80 ++++++++++++++++++++++++++++++------------------- src/repo.rs | 39 ++++++++++++------------ 3 files changed, 81 insertions(+), 53 deletions(-) diff --git a/src/grm/main.rs b/src/grm/main.rs index 8c2e8cd..c8b9a7d 100644 --- a/src/grm/main.rs +++ b/src/grm/main.rs @@ -3,9 +3,9 @@ use std::process; mod cmd; -use grm::repo; use grm::config; use grm::output::*; +use grm::repo; fn main() { let opts = cmd::parse(); @@ -177,7 +177,10 @@ fn main() { let worktree_config = match repo::read_worktree_root_config(&cwd) { Ok(config) => config, Err(error) => { - print_error(&format!("Error getting worktree configuration: {}", error)); + print_error(&format!( + "Error getting worktree configuration: {}", + error + )); process::exit(1); } }; @@ -187,8 +190,12 @@ fn main() { process::exit(1); }); - match repo.remove_worktree(&action_args.name, &worktree_dir, action_args.force, &worktree_config) - { + match repo.remove_worktree( + &action_args.name, + &worktree_dir, + action_args.force, + &worktree_config, + ) { Ok(_) => print_success(&format!("Worktree {} deleted", &action_args.name)), Err(error) => { match error { diff --git a/src/lib.rs b/src/lib.rs index b6151af..3a59f79 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -485,9 +485,7 @@ pub fn add_worktree( let mut remote_branch_exists = false; - let default_checkout = || { - repo.default_branch()?.to_commit() - }; + let default_checkout = || repo.default_branch()?.to_commit(); let checkout_commit; if no_track { @@ -507,32 +505,29 @@ pub fn add_worktree( } } } - None => { - match &config { + None => match &config { + None => checkout_commit = default_checkout()?, + Some(config) => match &config.track { None => checkout_commit = default_checkout()?, - Some(config) => { - match &config.track { - None => checkout_commit = default_checkout()?, - Some(track_config) => { - if track_config.default { - let remote_branch = repo.find_remote_branch(&track_config.default_remote, name); - match remote_branch { - Ok(branch) => { - remote_branch_exists = true; - checkout_commit = branch.to_commit()?; - } - Err(_) => { - checkout_commit = default_checkout()?; - } - } - } else { + Some(track_config) => { + if track_config.default { + let remote_branch = + repo.find_remote_branch(&track_config.default_remote, name); + match remote_branch { + Ok(branch) => { + remote_branch_exists = true; + checkout_commit = branch.to_commit()?; + } + Err(_) => { checkout_commit = default_checkout()?; } } + } else { + checkout_commit = default_checkout()?; } } - } - } + }, + }, }; } @@ -541,9 +536,17 @@ pub fn add_worktree( Err(_) => repo.create_branch(&branch_name, &checkout_commit)?, }; - fn push(remote: &mut repo::RemoteHandle, branch_name: &str, remote_branch_name: &str, repo: &repo::Repo) -> Result<(), String>{ + fn push( + remote: &mut repo::RemoteHandle, + branch_name: &str, + remote_branch_name: &str, + repo: &repo::Repo, + ) -> Result<(), String> { if !remote.is_pushable()? { - return Err(format!("Cannot push to non-pushable remote {}", remote.url())); + return Err(format!( + "Cannot push to non-pushable remote {}", + remote.url() + )); } remote.push(branch_name, remote_branch_name, repo) } @@ -558,7 +561,12 @@ pub fn add_worktree( .map_err(|error| format!("Error getting remote {}: {}", remote_name, error))? .ok_or_else(|| format!("Remote {} not found", remote_name))?; - push(&mut remote, &target_branch.name()?, remote_branch_name, &repo)?; + push( + &mut remote, + &target_branch.name()?, + remote_branch_name, + &repo, + )?; target_branch.set_upstream(remote_name, remote_branch_name)?; } @@ -570,19 +578,31 @@ pub fn add_worktree( target_branch.set_upstream(&remote_name, name)?; } else { let remote_branch_name = match track_config.default_remote_prefix { - Some(prefix) => format!("{}{}{}", &prefix, BRANCH_NAMESPACE_SEPARATOR, &name), + Some(prefix) => { + format!("{}{}{}", &prefix, BRANCH_NAMESPACE_SEPARATOR, &name) + } None => name.to_string(), }; let mut remote = repo .find_remote(&remote_name) - .map_err(|error| format!("Error getting remote {}: {}", remote_name, error))? + .map_err(|error| { + format!("Error getting remote {}: {}", remote_name, error) + })? .ok_or_else(|| format!("Remote {} not found", remote_name))?; if !remote.is_pushable()? { - return Err(format!("Cannot push to non-pushable remote {}", remote.url())); + return Err(format!( + "Cannot push to non-pushable remote {}", + remote.url() + )); } - push(&mut remote, &target_branch.name()?, &remote_branch_name, &repo)?; + push( + &mut remote, + &target_branch.name()?, + &remote_branch_name, + &repo, + )?; target_branch.set_upstream(&remote_name, &remote_branch_name)?; } diff --git a/src/repo.rs b/src/repo.rs index cba2011..a96f471 100644 --- a/src/repo.rs +++ b/src/repo.rs @@ -58,16 +58,22 @@ pub struct WorktreeRootConfig { pub track: Option, } -pub fn read_worktree_root_config(worktree_root: &Path) -> Result, String> { +pub fn read_worktree_root_config( + worktree_root: &Path, +) -> Result, String> { let path = worktree_root.join(WORKTREE_CONFIG_FILE_NAME); let content = match std::fs::read_to_string(&path) { Ok(s) => s, - Err(e) => { - match e.kind() { - std::io::ErrorKind::NotFound => return Ok(None), - _ => return Err(format!("Error reading configuration file \"{}\": {}", path.display(), e)), + Err(e) => match e.kind() { + std::io::ErrorKind::NotFound => return Ok(None), + _ => { + return Err(format!( + "Error reading configuration file \"{}\": {}", + path.display(), + e + )) } - } + }, }; let config: WorktreeRootConfig = match toml::from_str(&content) { @@ -75,7 +81,8 @@ pub fn read_worktree_root_config(worktree_root: &Path) -> Result { return Err(format!( "Error parsing configuration file \"{}\": {}", - path.display(), e + path.display(), + e )) } }; @@ -280,11 +287,7 @@ impl Repo { // unwrap() is safe here, as we can be certain that a branch with that // name exists let branch = self - .find_local_branch( - head - .shorthand() - .expect("Branch name is not valid utf-8"), - ) + .find_local_branch(head.shorthand().expect("Branch name is not valid utf-8")) .unwrap(); Ok(branch) } @@ -981,7 +984,8 @@ impl RemoteHandle<'_> { } pub fn is_pushable(&self) -> Result { - let remote_type = detect_remote_type(self.0.url().expect("Remote name is not valid utf-8")).ok_or_else(|| String::from("Could not detect remote type"))?; + let remote_type = detect_remote_type(self.0.url().expect("Remote name is not valid utf-8")) + .ok_or_else(|| String::from("Could not detect remote type"))?; Ok(matches!(remote_type, RemoteType::Ssh | RemoteType::File)) } @@ -1011,12 +1015,9 @@ impl RemoteHandle<'_> { Some(username) => username, None => panic!("Could not get username. This is a bug"), }; - git2::Cred::ssh_key_from_agent(username).or_else(|_| git2::Cred::ssh_key( - username, - None, - &crate::env_home().join(".ssh/id_rsa"), - None, - )) + git2::Cred::ssh_key_from_agent(username).or_else(|_| { + git2::Cred::ssh_key(username, None, &crate::env_home().join(".ssh/id_rsa"), None) + }) }); let mut push_options = git2::PushOptions::new(); From 92e4856dd98a503853179534e435306328db67e5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hannes=20K=C3=B6rber?= Date: Thu, 23 Dec 2021 18:33:14 +0100 Subject: [PATCH 35/68] Remove branch-namespace option --- src/grm/cmd.rs | 6 ------ src/grm/main.rs | 8 +------- src/lib.rs | 10 ++-------- 3 files changed, 3 insertions(+), 21 deletions(-) diff --git a/src/grm/cmd.rs b/src/grm/cmd.rs index 4532427..67b00b4 100644 --- a/src/grm/cmd.rs +++ b/src/grm/cmd.rs @@ -94,12 +94,6 @@ pub struct WorktreeAddArgs { #[clap(about = "Name of the worktree")] pub name: String, - #[clap( - short = 'n', - long = "branch-namespace", - about = "Namespace of the branch" - )] - pub branch_namespace: Option, #[clap(short = 't', long = "track", about = "Remote branch to track")] pub track: Option, diff --git a/src/grm/main.rs b/src/grm/main.rs index c8b9a7d..b2cd8c5 100644 --- a/src/grm/main.rs +++ b/src/grm/main.rs @@ -157,13 +157,7 @@ fn main() { None => None, }; - match grm::add_worktree( - &cwd, - &action_args.name, - action_args.branch_namespace.as_deref(), - track, - action_args.no_track, - ) { + match grm::add_worktree(&cwd, &action_args.name, track, action_args.no_track) { Ok(_) => print_success(&format!("Worktree {} created", &action_args.name)), Err(error) => { print_error(&format!("Error creating worktree: {}", error)); diff --git a/src/lib.rs b/src/lib.rs index 3a59f79..57fece9 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -461,7 +461,6 @@ pub fn find_in_tree(path: &Path) -> Result<(Tree, Vec), String> { pub fn add_worktree( directory: &Path, name: &str, - branch_namespace: Option<&str>, track: Option<(&str, &str)>, no_track: bool, ) -> Result<(), String> { @@ -478,11 +477,6 @@ pub fn add_worktree( return Err(format!("Worktree {} already exists", &name)); } - let branch_name = match branch_namespace { - Some(prefix) => format!("{}{}{}", &prefix, BRANCH_NAMESPACE_SEPARATOR, &name), - None => name.to_string(), - }; - let mut remote_branch_exists = false; let default_checkout = || repo.default_branch()?.to_commit(); @@ -531,9 +525,9 @@ pub fn add_worktree( }; } - let mut target_branch = match repo.find_local_branch(&branch_name) { + let mut target_branch = match repo.find_local_branch(name) { Ok(branchref) => branchref, - Err(_) => repo.create_branch(&branch_name, &checkout_commit)?, + Err(_) => repo.create_branch(name, &checkout_commit)?, }; fn push( From 61a8d63374d9d70f599ffc5444a5c5744672f965 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hannes=20K=C3=B6rber?= Date: Thu, 23 Dec 2021 18:33:14 +0100 Subject: [PATCH 36/68] Allow nested worktree directories --- e2e_tests/test_worktrees.py | 30 ++++++++++++++++++++++++++++++ src/grm/main.rs | 17 ++++++++++++++++- src/lib.rs | 13 +++++++++++-- src/repo.rs | 6 ++++-- 4 files changed, 61 insertions(+), 5 deletions(-) diff --git a/e2e_tests/test_worktrees.py b/e2e_tests/test_worktrees.py index 7539641..8d9f803 100644 --- a/e2e_tests/test_worktrees.py +++ b/e2e_tests/test_worktrees.py @@ -79,6 +79,36 @@ def test_worktree_add_simple(): assert repo.active_branch.tracking_branch() is None +def test_worktree_add_into_subdirectory(): + with TempGitRepositoryWorktree() as base_dir: + cmd = grm(["wt", "add", "dir/test"], cwd=base_dir) + assert cmd.returncode == 0 + + files = os.listdir(base_dir) + assert len(files) == 2 + assert set(files) == {".git-main-working-tree", "dir"} + + files = os.listdir(os.path.join(base_dir, "dir")) + assert set(files) == {"test"} + + repo = git.Repo(os.path.join(base_dir, "dir", "test")) + assert not repo.bare + assert not repo.is_dirty() + assert repo.active_branch.tracking_branch() is None + + +def test_worktree_add_into_invalid_subdirectory(): + with TempGitRepositoryWorktree() as base_dir: + cmd = grm(["wt", "add", "/dir/test"], cwd=base_dir) + assert cmd.returncode == 1 + assert "dir" not in os.listdir(base_dir) + assert "dir" not in os.listdir("/") + + cmd = grm(["wt", "add", "dir/"], cwd=base_dir) + assert cmd.returncode == 1 + assert "dir" not in os.listdir(base_dir) + + def test_worktree_add_with_tracking(): for remote_branch_already_exists in (True, False): for has_config in (True, False): diff --git a/src/grm/main.rs b/src/grm/main.rs index b2cd8c5..29041c0 100644 --- a/src/grm/main.rs +++ b/src/grm/main.rs @@ -157,7 +157,22 @@ fn main() { None => None, }; - match grm::add_worktree(&cwd, &action_args.name, track, action_args.no_track) { + let mut name: &str = &action_args.name; + let subdirectory; + let split = name.split_once('/'); + match split { + None => subdirectory = None, + Some(split) => { + if split.0.is_empty() || split.1.is_empty() { + print_error("Worktree name cannot start or end with a slash"); + process::exit(1); + } else { + (subdirectory, name) = (Some(Path::new(split.0)), split.1); + } + } + } + + match grm::add_worktree(&cwd, name, subdirectory, track, action_args.no_track) { Ok(_) => print_success(&format!("Worktree {} created", &action_args.name)), Err(error) => { print_error(&format!("Error creating worktree: {}", error)); diff --git a/src/lib.rs b/src/lib.rs index 57fece9..3bc321b 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -461,6 +461,7 @@ pub fn find_in_tree(path: &Path) -> Result<(Tree, Vec), String> { pub fn add_worktree( directory: &Path, name: &str, + subdirectory: Option<&Path>, track: Option<(&str, &str)>, no_track: bool, ) -> Result<(), String> { @@ -473,7 +474,12 @@ pub fn add_worktree( let config = repo::read_worktree_root_config(directory)?; - if repo.find_worktree(name).is_ok() { + let path = match subdirectory { + Some(dir) => dir.join(name), + None => Path::new(name).to_path_buf(), + }; + + if repo.find_worktree(&path).is_ok() { return Err(format!("Worktree {} already exists", &name)); } @@ -605,7 +611,10 @@ pub fn add_worktree( } } - repo.new_worktree(name, &directory.join(&name), &target_branch)?; + if let Some(subdirectory) = subdirectory { + std::fs::create_dir_all(subdirectory).map_err(|error| error.to_string())?; + } + repo.new_worktree(name, &path, &target_branch)?; Ok(()) } diff --git a/src/repo.rs b/src/repo.rs index a96f471..64c2535 100644 --- a/src/repo.rs +++ b/src/repo.rs @@ -362,8 +362,10 @@ impl Repo { self.0.config().map_err(convert_libgit2_error) } - pub fn find_worktree(&self, name: &str) -> Result<(), String> { - self.0.find_worktree(name).map_err(convert_libgit2_error)?; + pub fn find_worktree(&self, path: &Path) -> Result<(), String> { + self.0 + .find_worktree(path.to_str().expect("Worktree path is not valid utf-8")) + .map_err(convert_libgit2_error)?; Ok(()) } From 54fc48b37d170f90e4e06d222a066b95c9004b44 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hannes=20K=C3=B6rber?= Date: Thu, 23 Dec 2021 18:33:14 +0100 Subject: [PATCH 37/68] Cargo.lock: Updating syn v1.0.82 -> v1.0.83 --- Cargo.lock | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 4944217..9e34db5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -684,9 +684,9 @@ dependencies = [ [[package]] name = "syn" -version = "1.0.82" +version = "1.0.83" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8daf5dd0bb60cbd4137b1b587d2fc0ae729bc07cf01cd70b36a1ed5ade3b9d59" +checksum = "23a1dfb999630e338648c83e91c59a4e9fb7620f520c3194b6b89e276f2f1959" dependencies = [ "proc-macro2", "quote", From 3ac88260b572e2c1c76f7a5a807010825f815fa3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hannes=20K=C3=B6rber?= Date: Thu, 23 Dec 2021 18:33:14 +0100 Subject: [PATCH 38/68] Parameterize e2e tests using pytest --- e2e_tests/test_worktrees.py | 473 +++++++++++++++++------------------- 1 file changed, 225 insertions(+), 248 deletions(-) diff --git a/e2e_tests/test_worktrees.py b/e2e_tests/test_worktrees.py index 8d9f803..0e95659 100644 --- a/e2e_tests/test_worktrees.py +++ b/e2e_tests/test_worktrees.py @@ -3,80 +3,75 @@ from helpers import * import git +import pytest import os.path -def test_worktree_add_simple(): - for remote_branch_already_exists in (True, False): - for has_config in (True, False): - for has_default in (True, False): - for has_prefix in (True, False): - with TempGitRepositoryWorktree() as base_dir: - if has_config: - with open(os.path.join(base_dir, "grm.toml"), "w") as f: - f.write( - f""" - [track] - default = {str(has_default).lower()} - default_remote = "origin" - """ - ) - if has_prefix: - f.write( - """ - default_remote_prefix = "myprefix" - """ - ) +@pytest.mark.parametrize("remote_branch_already_exists", [True, False]) +@pytest.mark.parametrize("has_config", [True, False]) +@pytest.mark.parametrize("has_default", [True, False]) +@pytest.mark.parametrize("has_prefix", [True, False]) +def test_worktree_add_simple( + remote_branch_already_exists, has_config, has_default, has_prefix +): + with TempGitRepositoryWorktree() as base_dir: + if has_config: + with open(os.path.join(base_dir, "grm.toml"), "w") as f: + f.write( + f""" + [track] + default = {str(has_default).lower()} + default_remote = "origin" + """ + ) + if has_prefix: + f.write( + """ + default_remote_prefix = "myprefix" + """ + ) - if remote_branch_already_exists: - shell( - f""" - cd {base_dir} - git --git-dir ./.git-main-working-tree worktree add tmp - ( - cd tmp - touch change - git add change - git commit -m commit - git push origin HEAD:test - #git reset --hard 'HEAD@{1}' - git branch -va - ) - git --git-dir ./.git-main-working-tree worktree remove tmp - """ - ) - cmd = grm(["wt", "add", "test"], cwd=base_dir) - assert cmd.returncode == 0 + if remote_branch_already_exists: + shell( + f""" + cd {base_dir} + git --git-dir ./.git-main-working-tree worktree add tmp + ( + cd tmp + touch change + git add change + git commit -m commit + git push origin HEAD:test + #git reset --hard 'HEAD@{1}' + git branch -va + ) + git --git-dir ./.git-main-working-tree worktree remove tmp + """ + ) + cmd = grm(["wt", "add", "test"], cwd=base_dir) + assert cmd.returncode == 0 - files = os.listdir(base_dir) - if has_config is True: - assert len(files) == 3 - assert set(files) == { - ".git-main-working-tree", - "grm.toml", - "test", - } - else: - assert len(files) == 2 - assert set(files) == {".git-main-working-tree", "test"} + files = os.listdir(base_dir) + if has_config is True: + assert len(files) == 3 + assert set(files) == {".git-main-working-tree", "grm.toml", "test"} + else: + assert len(files) == 2 + assert set(files) == {".git-main-working-tree", "test"} - repo = git.Repo(os.path.join(base_dir, "test")) - assert not repo.bare - assert not repo.is_dirty() - if has_config and has_default: - if has_prefix and not remote_branch_already_exists: - assert ( - str(repo.active_branch.tracking_branch()) - == "origin/myprefix/test" - ) - else: - assert ( - str(repo.active_branch.tracking_branch()) - == "origin/test" - ) - else: - assert repo.active_branch.tracking_branch() is None + repo = git.Repo(os.path.join(base_dir, "test")) + assert not repo.bare + assert not repo.is_dirty() + if has_config and has_default: + if has_prefix and not remote_branch_already_exists: + assert ( + str(repo.active_branch.tracking_branch()) == "origin/myprefix/test" + ) + else: + assert str(repo.active_branch.tracking_branch()) == "origin/test" + else: + assert repo.active_branch.tracking_branch() is None def test_worktree_add_into_subdirectory(): @@ -109,191 +104,173 @@ def test_worktree_add_into_invalid_subdirectory(): assert "dir" not in os.listdir(base_dir) -def test_worktree_add_with_tracking(): - for remote_branch_already_exists in (True, False): - for has_config in (True, False): - for has_default in (True, False): - for has_prefix in (True, False): - with TempGitRepositoryWorktree() as base_dir: - if has_config: - with open(os.path.join(base_dir, "grm.toml"), "w") as f: - f.write( - f""" - [track] - default = {str(has_default).lower()} - default_remote = "origin" - """ - ) - if has_prefix: - f.write( - """ - default_remote_prefix = "myprefix" - """ - ) - - if remote_branch_already_exists: - shell( - f""" - cd {base_dir} - git --git-dir ./.git-main-working-tree worktree add tmp - ( - cd tmp - touch change - git add change - git commit -m commit - git push origin HEAD:test - #git reset --hard 'HEAD@{1}' - git branch -va - ) - git --git-dir ./.git-main-working-tree worktree remove tmp - """ - ) - cmd = grm( - ["wt", "add", "test", "--track", "origin/test"], - cwd=base_dir, - ) - print(cmd.stderr) - assert cmd.returncode == 0 - - files = os.listdir(base_dir) - if has_config is True: - assert len(files) == 3 - assert set(files) == { - ".git-main-working-tree", - "grm.toml", - "test", - } - else: - assert len(files) == 2 - assert set(files) == {".git-main-working-tree", "test"} - - repo = git.Repo(os.path.join(base_dir, "test")) - assert not repo.bare - assert not repo.is_dirty() - assert str(repo.active_branch) == "test" - assert ( - str(repo.active_branch.tracking_branch()) == "origin/test" - ) - - -def test_worktree_add_with_explicit_no_tracking(): - for has_config in (True, False): - for has_default in (True, False): - for has_prefix in (True, False): - for track in (True, False): - with TempGitRepositoryWorktree() as base_dir: - if has_config: - with open(os.path.join(base_dir, "grm.toml"), "w") as f: - f.write( - f""" - [track] - default = {str(has_default).lower()} - default_remote = "origin" - """ - ) - if has_prefix: - f.write( - """ - default_remote_prefix = "myprefix" - """ - ) - if track is True: - cmd = grm( - [ - "wt", - "add", - "test", - "--track", - "origin/test", - "--no-track", - ], - cwd=base_dir, - ) - else: - cmd = grm(["wt", "add", "test", "--no-track"], cwd=base_dir) - print(cmd.stderr) - assert cmd.returncode == 0 - - files = os.listdir(base_dir) - if has_config is True: - assert len(files) == 3 - assert set(files) == { - ".git-main-working-tree", - "grm.toml", - "test", - } - else: - assert len(files) == 2 - assert set(files) == {".git-main-working-tree", "test"} - - repo = git.Repo(os.path.join(base_dir, "test")) - assert not repo.bare - assert not repo.is_dirty() - assert str(repo.active_branch) == "test" - assert repo.active_branch.tracking_branch() is None - - -def test_worktree_add_with_config(): - for remote_branch_already_exists in (True, False): - for has_default in (True, False): - for has_prefix in (True, False): - with TempGitRepositoryWorktree() as base_dir: - with open(os.path.join(base_dir, "grm.toml"), "w") as f: - f.write( - f""" - [track] - default = {str(has_default).lower()} - default_remote = "origin" +@pytest.mark.parametrize("remote_branch_already_exists", [True, False]) +@pytest.mark.parametrize("has_config", [True, False]) +@pytest.mark.parametrize("has_default", [True, False]) +@pytest.mark.parametrize("has_prefix", [True, False]) +def test_worktree_add_with_tracking( + remote_branch_already_exists, has_config, has_default, has_prefix +): + with TempGitRepositoryWorktree() as base_dir: + if has_config: + with open(os.path.join(base_dir, "grm.toml"), "w") as f: + f.write( + f""" + [track] + default = {str(has_default).lower()} + default_remote = "origin" + """ + ) + if has_prefix: + f.write( """ - ) - if has_prefix: - f.write( - """ - default_remote_prefix = "myprefix" - """ - ) - if remote_branch_already_exists: - shell( - f""" - cd {base_dir} - git --git-dir ./.git-main-working-tree worktree add tmp - ( - cd tmp - touch change - git add change - git commit -m commit - git push origin HEAD:test - #git reset --hard 'HEAD@{1}' - git branch -va - ) - git --git-dir ./.git-main-working-tree worktree remove tmp + default_remote_prefix = "myprefix" + """ + ) + + if remote_branch_already_exists: + shell( + f""" + cd {base_dir} + git --git-dir ./.git-main-working-tree worktree add tmp + ( + cd tmp + touch change + git add change + git commit -m commit + git push origin HEAD:test + #git reset --hard 'HEAD@{1}' + git branch -va + ) + git --git-dir ./.git-main-working-tree worktree remove tmp + """ + ) + cmd = grm(["wt", "add", "test", "--track", "origin/test"], cwd=base_dir) + print(cmd.stderr) + assert cmd.returncode == 0 + + files = os.listdir(base_dir) + if has_config is True: + assert len(files) == 3 + assert set(files) == {".git-main-working-tree", "grm.toml", "test"} + else: + assert len(files) == 2 + assert set(files) == {".git-main-working-tree", "test"} + + repo = git.Repo(os.path.join(base_dir, "test")) + assert not repo.bare + assert not repo.is_dirty() + assert str(repo.active_branch) == "test" + assert str(repo.active_branch.tracking_branch()) == "origin/test" + + +@pytest.mark.parametrize("has_config", [True, False]) +@pytest.mark.parametrize("has_default", [True, False]) +@pytest.mark.parametrize("has_prefix", [True, False]) +@pytest.mark.parametrize("track", [True, False]) +def test_worktree_add_with_explicit_no_tracking( + has_config, has_default, has_prefix, track +): + with TempGitRepositoryWorktree() as base_dir: + if has_config: + with open(os.path.join(base_dir, "grm.toml"), "w") as f: + f.write( + f""" + [track] + default = {str(has_default).lower()} + default_remote = "origin" + """ + ) + if has_prefix: + f.write( """ - ) - cmd = grm(["wt", "add", "test"], cwd=base_dir) - print(cmd.stderr) - assert cmd.returncode == 0 + default_remote_prefix = "myprefix" + """ + ) + if track is True: + cmd = grm( + ["wt", "add", "test", "--track", "origin/test", "--no-track"], + cwd=base_dir, + ) + else: + cmd = grm(["wt", "add", "test", "--no-track"], cwd=base_dir) + print(cmd.stderr) + assert cmd.returncode == 0 - files = os.listdir(base_dir) - assert len(files) == 3 - assert set(files) == {".git-main-working-tree", "grm.toml", "test"} + files = os.listdir(base_dir) + if has_config is True: + assert len(files) == 3 + assert set(files) == {".git-main-working-tree", "grm.toml", "test"} + else: + assert len(files) == 2 + assert set(files) == {".git-main-working-tree", "test"} - repo = git.Repo(os.path.join(base_dir, "test")) - assert not repo.bare - assert not repo.is_dirty() - assert str(repo.active_branch) == "test" - if has_default: - if has_prefix and not remote_branch_already_exists: - assert ( - str(repo.active_branch.tracking_branch()) - == "origin/myprefix/test" - ) - else: - assert ( - str(repo.active_branch.tracking_branch()) - == "origin/test" - ) - else: - assert repo.active_branch.tracking_branch() is None + repo = git.Repo(os.path.join(base_dir, "test")) + assert not repo.bare + assert not repo.is_dirty() + assert str(repo.active_branch) == "test" + assert repo.active_branch.tracking_branch() is None + + +@pytest.mark.parametrize("remote_branch_already_exists", [True, False]) +@pytest.mark.parametrize("has_default", [True, False]) +@pytest.mark.parametrize("has_prefix", [True, False]) +def test_worktree_add_with_config( + remote_branch_already_exists, has_default, has_prefix +): + with TempGitRepositoryWorktree() as base_dir: + with open(os.path.join(base_dir, "grm.toml"), "w") as f: + f.write( + f""" + [track] + default = {str(has_default).lower()} + default_remote = "origin" + """ + ) + if has_prefix: + f.write( + """ + default_remote_prefix = "myprefix" + """ + ) + if remote_branch_already_exists: + shell( + f""" + cd {base_dir} + git --git-dir ./.git-main-working-tree worktree add tmp + ( + cd tmp + touch change + git add change + git commit -m commit + git push origin HEAD:test + #git reset --hard 'HEAD@{1}' + git branch -va + ) + git --git-dir ./.git-main-working-tree worktree remove tmp + """ + ) + cmd = grm(["wt", "add", "test"], cwd=base_dir) + print(cmd.stderr) + assert cmd.returncode == 0 + + files = os.listdir(base_dir) + assert len(files) == 3 + assert set(files) == {".git-main-working-tree", "grm.toml", "test"} + + repo = git.Repo(os.path.join(base_dir, "test")) + assert not repo.bare + assert not repo.is_dirty() + assert str(repo.active_branch) == "test" + if has_default: + if has_prefix and not remote_branch_already_exists: + assert ( + str(repo.active_branch.tracking_branch()) == "origin/myprefix/test" + ) + else: + assert str(repo.active_branch.tracking_branch()) == "origin/test" + else: + assert repo.active_branch.tracking_branch() is None def test_worktree_delete(): From 3ff7b61518485d746bce148f38ad55b072e3a969 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hannes=20K=C3=B6rber?= Date: Thu, 23 Dec 2021 18:33:14 +0100 Subject: [PATCH 39/68] Refuse to convert to worktree with ignored files --- src/grm/main.rs | 24 +++++++------ src/repo.rs | 94 +++++++++++++++++++++++++++++++++++++++++++------ 2 files changed, 97 insertions(+), 21 deletions(-) diff --git a/src/grm/main.rs b/src/grm/main.rs index 29041c0..dea98a3 100644 --- a/src/grm/main.rs +++ b/src/grm/main.rs @@ -258,18 +258,22 @@ fn main() { process::exit(1); }); - let status = repo.status(false).unwrap_or_else(|error| { - print_error(&format!("Failed getting repo changes: {}", error)); - process::exit(1); - }); - if status.changes.is_some() { - print_error("Changes found in repository, refusing to convert"); - process::exit(1); - } - match repo.convert_to_worktree(&cwd) { Ok(_) => print_success("Conversion done"), - Err(error) => print_error(&format!("Error during conversion: {}", error)), + Err(reason) => { + match reason { + repo::WorktreeConversionFailureReason::Changes => { + print_error("Changes found in repository, refusing to convert"); + } + repo::WorktreeConversionFailureReason::Ignored => { + print_error("Ignored files found in repository, refusing to convert. Run git clean -f -d -X to remove them manually."); + } + repo::WorktreeConversionFailureReason::Error(error) => { + print_error(&format!("Error during conversion: {}", error)); + } + } + process::exit(1); + } } } cmd::WorktreeAction::Clean(_args) => { diff --git a/src/repo.rs b/src/repo.rs index 64c2535..b60eb5e 100644 --- a/src/repo.rs +++ b/src/repo.rs @@ -21,6 +21,12 @@ pub enum WorktreeRemoveFailureReason { NotMerged(String), } +pub enum WorktreeConversionFailureReason { + Changes, + Ignored, + Error(String), +} + pub enum GitPushDefaultSetting { Upstream, } @@ -414,14 +420,40 @@ impl Repo { .map_err(|error| format!("Could not set {}: {}", crate::GIT_CONFIG_BARE_KEY, error)) } - pub fn convert_to_worktree(&self, root_dir: &Path) -> Result<(), String> { - std::fs::rename(".git", crate::GIT_MAIN_WORKTREE_DIRECTORY) - .map_err(|error| format!("Error moving .git directory: {}", error))?; + pub fn convert_to_worktree( + &self, + root_dir: &Path, + ) -> Result<(), WorktreeConversionFailureReason> { + if self + .status(false) + .map_err(WorktreeConversionFailureReason::Error)? + .changes + .is_some() + { + return Err(WorktreeConversionFailureReason::Changes); + } + + if self + .has_untracked_files(false) + .map_err(WorktreeConversionFailureReason::Error)? + { + return Err(WorktreeConversionFailureReason::Ignored); + } + + std::fs::rename(".git", crate::GIT_MAIN_WORKTREE_DIRECTORY).map_err(|error| { + WorktreeConversionFailureReason::Error(format!( + "Error moving .git directory: {}", + error + )) + })?; for entry in match std::fs::read_dir(&root_dir) { Ok(iterator) => iterator, Err(error) => { - return Err(format!("Opening directory failed: {}", error)); + return Err(WorktreeConversionFailureReason::Error(format!( + "Opening directory failed: {}", + error + ))); } } { match entry { @@ -433,28 +465,41 @@ impl Repo { } if path.is_file() || path.is_symlink() { if let Err(error) = std::fs::remove_file(&path) { - return Err(format!("Failed removing {}", error)); + return Err(WorktreeConversionFailureReason::Error(format!( + "Failed removing {}", + error + ))); } } else if let Err(error) = std::fs::remove_dir_all(&path) { - return Err(format!("Failed removing {}", error)); + return Err(WorktreeConversionFailureReason::Error(format!( + "Failed removing {}", + error + ))); } } Err(error) => { - return Err(format!("Error getting directory entry: {}", error)); + return Err(WorktreeConversionFailureReason::Error(format!( + "Error getting directory entry: {}", + error + ))); } } } - let worktree_repo = Repo::open(root_dir, true) - .map_err(|error| format!("Opening newly converted repository failed: {}", error))?; + let worktree_repo = Repo::open(root_dir, true).map_err(|error| { + WorktreeConversionFailureReason::Error(format!( + "Opening newly converted repository failed: {}", + error + )) + })?; worktree_repo .make_bare(true) - .map_err(|error| format!("Error: {}", error))?; + .map_err(|error| WorktreeConversionFailureReason::Error(format!("Error: {}", error)))?; worktree_repo .set_config_push(GitPushDefaultSetting::Upstream) - .map_err(|error| format!("Error: {}", error))?; + .map_err(|error| WorktreeConversionFailureReason::Error(format!("Error: {}", error)))?; Ok(()) } @@ -478,6 +523,33 @@ impl Repo { }) } + pub fn has_untracked_files(&self, is_worktree: bool) -> Result { + match is_worktree { + true => Err(String::from( + "Cannot get changes as this is a bare worktree repository", + )), + false => { + let statuses = self + .0 + .statuses(Some(git2::StatusOptions::new().include_ignored(true))) + .map_err(convert_libgit2_error)?; + + match statuses.is_empty() { + true => Ok(false), + false => { + for status in statuses.iter() { + let status_bits = status.status(); + if status_bits.intersects(git2::Status::IGNORED) { + return Ok(true); + } + } + Ok(false) + } + } + } + } + } + pub fn status(&self, is_worktree: bool) -> Result { let operation = match self.0.state() { git2::RepositoryState::Clean => None, From ae9a928d4548bdda35768579cbddae0d9eb00ea9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hannes=20K=C3=B6rber?= Date: Thu, 23 Dec 2021 18:33:14 +0100 Subject: [PATCH 40/68] Detect default branch from grm.toml if possible --- docs/src/worktrees.md | 3 ++ e2e_tests/test_worktree_clean.py | 55 +++++++++++++++++++++++++++++++ src/repo.rs | 56 ++++++++++++++++++++++++-------- 3 files changed, 100 insertions(+), 14 deletions(-) diff --git a/docs/src/worktrees.md b/docs/src/worktrees.md index 7dd4799..baa1f4b 100644 --- a/docs/src/worktrees.md +++ b/docs/src/worktrees.md @@ -259,6 +259,9 @@ persistent_branches = [ ] ``` +Note that setting persistent branches will disable any detection of "default" +branches. The first entry will be considered your repositories' default branch. + ### Converting an existing repository It is possible to convert an existing directory to a worktree setup, using `grm diff --git a/e2e_tests/test_worktree_clean.py b/e2e_tests/test_worktree_clean.py index fa916e1..17f16ce 100644 --- a/e2e_tests/test_worktree_clean.py +++ b/e2e_tests/test_worktree_clean.py @@ -1,5 +1,7 @@ #!/usr/bin/env python3 +import pytest + from helpers import * @@ -139,3 +141,56 @@ def test_worktree_clean_non_git(): assert cmd.returncode != 0 assert len(cmd.stdout) == 0 assert len(cmd.stderr) != 0 + + +@pytest.mark.parametrize("configure_default_branch", [True, False]) +@pytest.mark.parametrize("branch_list_empty", [True, False]) +def test_worktree_clean_configured_default_branch( + configure_default_branch, branch_list_empty +): + with TempGitRepositoryWorktree() as (base_dir, _commit): + if configure_default_branch: + with open(os.path.join(base_dir, "grm.toml"), "w") as f: + if branch_list_empty: + f.write( + f""" + persistent_branches = [] + """ + ) + else: + f.write( + f""" + persistent_branches = [ + "mybranch" + ] + """ + ) + + cmd = grm(["wt", "add", "test"], cwd=base_dir) + assert cmd.returncode == 0 + + shell( + f""" + cd {base_dir} + ( + cd ./test + touch change + git add change + git commit -m commit + ) + + git --git-dir ./.git-main-working-tree worktree add mybranch + ( + cd ./mybranch + git merge --no-ff test + ) + git --git-dir ./.git-main-working-tree worktree remove mybranch + """ + ) + + cmd = grm(["wt", "clean"], cwd=base_dir) + assert cmd.returncode == 0 + if configure_default_branch and not branch_list_empty: + assert "test" not in os.listdir(base_dir) + else: + assert "test" in os.listdir(base_dir) diff --git a/src/repo.rs b/src/repo.rs index b60eb5e..2daba33 100644 --- a/src/repo.rs +++ b/src/repo.rs @@ -880,16 +880,29 @@ impl Repo { .get_worktrees() .map_err(|error| format!("Getting worktrees failed: {}", error))?; - let default_branch = self - .default_branch() - .map_err(|error| format!("Failed getting default branch: {}", error))?; - - let default_branch_name = default_branch - .name() - .map_err(|error| format!("Failed getting default branch name: {}", error))?; - let config = read_worktree_root_config(directory)?; + let guess_default_branch = || { + self.default_branch() + .map_err(|_| "Could not determine default branch")? + .name() + .map_err(|error| format!("Failed getting default branch name: {}", error)) + }; + + let default_branch_name = match &config { + None => guess_default_branch()?, + Some(config) => match &config.persistent_branches { + None => guess_default_branch()?, + Some(persistent_branches) => { + if persistent_branches.is_empty() { + guess_default_branch()? + } else { + persistent_branches[0].clone() + } + } + }, + }; + for worktree in worktrees .iter() .filter(|worktree| *worktree != &default_branch_name) @@ -946,13 +959,28 @@ impl Repo { .unwrap(), ); - let default_branch = self - .default_branch() - .map_err(|error| format!("Failed getting default branch: {}", error))?; + let config = read_worktree_root_config(directory)?; - let default_branch_name = default_branch - .name() - .map_err(|error| format!("Failed getting default branch name: {}", error))?; + let guess_default_branch = || { + self.default_branch() + .map_err(|error| format!("Failed getting default branch: {}", error))? + .name() + .map_err(|error| format!("Failed getting default branch name: {}", error)) + }; + + let default_branch_name = match &config { + None => guess_default_branch()?, + Some(config) => match &config.persistent_branches { + None => guess_default_branch()?, + Some(persistent_branches) => { + if persistent_branches.is_empty() { + guess_default_branch()? + } else { + persistent_branches[0].clone() + } + } + }, + }; if dirname == crate::GIT_MAIN_WORKTREE_DIRECTORY { continue; From cbc9792755ffa7297334efb1a185df2e1150acec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hannes=20K=C3=B6rber?= Date: Wed, 29 Dec 2021 20:38:41 +0100 Subject: [PATCH 41/68] Cargo.lock: Updating proc-macro2 v1.0.34 -> v1.0.36 --- Cargo.lock | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 9e34db5..9b079b0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -489,18 +489,18 @@ dependencies = [ [[package]] name = "proc-macro2" -version = "1.0.34" +version = "1.0.36" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f84e92c0f7c9d58328b85a78557813e4bd845130db68d7184635344399423b1" +checksum = "c7342d5883fbccae1cc37a2353b09c87c9b0f3afd73f5fb9bba687a1f733b029" dependencies = [ "unicode-xid", ] [[package]] name = "quote" -version = "1.0.10" +version = "1.0.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "38bc8cc6a5f2e3655e0899c1b848643b2562f853f114bfec7be120678e3ace05" +checksum = "47aa80447ce4daf1717500037052af176af5d38cc3e571d9ec1c7353fc10c87d" dependencies = [ "proc-macro2", ] @@ -684,9 +684,9 @@ dependencies = [ [[package]] name = "syn" -version = "1.0.83" +version = "1.0.84" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "23a1dfb999630e338648c83e91c59a4e9fb7620f520c3194b6b89e276f2f1959" +checksum = "ecb2e6da8ee5eb9a61068762a32fa9619cc591ceb055b3687f4cd4051ec2e06b" dependencies = [ "proc-macro2", "quote", @@ -817,9 +817,9 @@ checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" [[package]] name = "version_check" -version = "0.9.3" +version = "0.9.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5fecdca9a5291cc2b8dcf7dc02453fee791a280f3743cb0905f8822ae463b3fe" +checksum = "49874b5167b65d7193b8aba1567f5c7d93d001cafc34600cee003eda787e483f" [[package]] name = "wasi" From fcbad5a3eb1b8c254728a01f153ae4b2001a06f4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hannes=20K=C3=B6rber?= Date: Wed, 29 Dec 2021 18:42:57 +0100 Subject: [PATCH 42/68] Refactor worktree into own struct --- src/repo.rs | 40 ++++++++++++++++++++++++++++++---------- src/table.rs | 16 ++++++++++------ 2 files changed, 40 insertions(+), 16 deletions(-) diff --git a/src/repo.rs b/src/repo.rs index 2daba33..d66f8e7 100644 --- a/src/repo.rs +++ b/src/repo.rs @@ -166,6 +166,22 @@ pub struct RepoStatus { pub branches: Vec<(String, Option<(String, RemoteTrackingStatus)>)>, } +pub struct Worktree { + name: String, +} + +impl Worktree { + pub fn new(name: &str) -> Self { + Self { + name: name.to_string(), + } + } + + pub fn name(&self) -> &str { + &self.name + } +} + #[cfg(test)] mod tests { use super::*; @@ -746,14 +762,14 @@ impl Repo { ))) } - pub fn get_worktrees(&self) -> Result, String> { + pub fn get_worktrees(&self) -> Result, String> { Ok(self .0 .worktrees() .map_err(convert_libgit2_error)? .iter() .map(|name| name.expect("Worktree name is invalid utf-8")) - .map(|name| name.to_string()) + .map(Worktree::new) .collect()) } @@ -905,24 +921,25 @@ impl Repo { for worktree in worktrees .iter() - .filter(|worktree| *worktree != &default_branch_name) + .filter(|worktree| worktree.name() != default_branch_name) .filter(|worktree| match &config { None => true, Some(config) => match &config.persistent_branches { None => true, - Some(branches) => !branches.contains(worktree), + Some(branches) => !branches.iter().any(|branch| branch == worktree.name()), }, }) { - let repo_dir = &directory.join(&worktree); + let repo_dir = &directory.join(&worktree.name()); if repo_dir.exists() { - match self.remove_worktree(worktree, repo_dir, false, &config) { - Ok(_) => print_success(&format!("Worktree {} deleted", &worktree)), + match self.remove_worktree(worktree.name(), repo_dir, false, &config) { + Ok(_) => print_success(&format!("Worktree {} deleted", &worktree.name())), Err(error) => match error { WorktreeRemoveFailureReason::Changes(changes) => { warnings.push(format!( "Changes found in {}: {}, skipping", - &worktree, &changes + &worktree.name(), + &changes )); continue; } @@ -936,7 +953,10 @@ impl Repo { }, } } else { - warnings.push(format!("Worktree {} does not have a directory", &worktree)); + warnings.push(format!( + "Worktree {} does not have a directory", + &worktree.name() + )); } } Ok(warnings) @@ -991,7 +1011,7 @@ impl Repo { if dirname == default_branch_name { continue; } - if !&worktrees.contains(&dirname) { + if !&worktrees.iter().any(|worktree| worktree.name() == dirname) { unmanaged_worktrees.push(dirname); } } diff --git a/src/table.rs b/src/table.rs index f48ce5d..6fa7ed2 100644 --- a/src/table.rs +++ b/src/table.rs @@ -104,14 +104,15 @@ pub fn get_worktree_status_table( add_worktree_table_header(&mut table); for worktree in &worktrees { - let worktree_dir = &directory.join(&worktree); + let worktree_dir = &directory.join(&worktree.name()); if worktree_dir.exists() { let repo = match crate::Repo::open(worktree_dir, false) { Ok(repo) => repo, Err(error) => { errors.push(format!( "Failed opening repo of worktree {}: {}", - &worktree, &error + &worktree.name(), + &error )); continue; } @@ -120,7 +121,10 @@ pub fn get_worktree_status_table( errors.push(error); } } else { - errors.push(format!("Worktree {} does not have a directory", &worktree)); + errors.push(format!( + "Worktree {} does not have a directory", + &worktree.name() + )); } } for entry in std::fs::read_dir(&directory).map_err(|error| error.to_string())? { @@ -136,7 +140,7 @@ pub fn get_worktree_status_table( if dirname == crate::GIT_MAIN_WORKTREE_DIRECTORY { continue; } - if !&worktrees.contains(&dirname) { + if !&worktrees.iter().any(|worktree| worktree.name() == dirname) { errors.push(format!( "Found {}, which is not a valid worktree directory!", &dirname @@ -211,7 +215,7 @@ fn add_worktree_table_header(table: &mut Table) { fn add_worktree_status( table: &mut Table, - worktree_name: &str, + worktree: &crate::repo::Worktree, repo: &crate::Repo, ) -> Result<(), String> { let repo_status = repo.status(false)?; @@ -245,7 +249,7 @@ fn add_worktree_status( }; table.add_row(vec![ - worktree_name, + worktree.name(), &match repo_status.changes { Some(changes) => { let mut out = Vec::new(); From ef381c742142e75cbe6942097088b6393103a542 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hannes=20K=C3=B6rber?= Date: Fri, 31 Dec 2021 10:54:07 +0100 Subject: [PATCH 43/68] e2e-tests: Return root commit SHA for worktree repo --- e2e_tests/helpers.py | 5 +++- e2e_tests/test_worktree_clean.py | 16 +++++----- .../test_worktree_config_presistent_branch.py | 8 ++--- e2e_tests/test_worktree_conversion.py | 2 +- e2e_tests/test_worktree_status.py | 4 +-- e2e_tests/test_worktrees.py | 30 +++++++++---------- 6 files changed, 34 insertions(+), 31 deletions(-) diff --git a/e2e_tests/helpers.py b/e2e_tests/helpers.py index 2cd507c..7ed8137 100644 --- a/e2e_tests/helpers.py +++ b/e2e_tests/helpers.py @@ -174,7 +174,10 @@ class TempGitRepositoryWorktree: git --git-dir .git-main-working-tree config core.bare true """ ) - return self.tmpdir.name + commit = git.Repo( + f"{self.tmpdir.name}/.git-main-working-tree" + ).head.commit.hexsha + return (self.tmpdir.name, commit) def __exit__(self, exc_type, exc_val, exc_tb): del self.tmpdir diff --git a/e2e_tests/test_worktree_clean.py b/e2e_tests/test_worktree_clean.py index 17f16ce..bdcf22e 100644 --- a/e2e_tests/test_worktree_clean.py +++ b/e2e_tests/test_worktree_clean.py @@ -6,7 +6,7 @@ from helpers import * def test_worktree_clean(): - with TempGitRepositoryWorktree() as base_dir: + with TempGitRepositoryWorktree() as (base_dir, _commit): cmd = grm(["wt", "add", "test", "--track", "origin/test"], cwd=base_dir) assert cmd.returncode == 0 assert "test" in os.listdir(base_dir) @@ -17,7 +17,7 @@ def test_worktree_clean(): def test_worktree_clean_refusal_no_tracking_branch(): - with TempGitRepositoryWorktree() as base_dir: + with TempGitRepositoryWorktree() as (base_dir, _commit): cmd = grm(["wt", "add", "test"], cwd=base_dir) assert cmd.returncode == 0 @@ -31,7 +31,7 @@ def test_worktree_clean_refusal_no_tracking_branch(): def test_worktree_clean_refusal_uncommited_changes_new_file(): - with TempGitRepositoryWorktree() as base_dir: + with TempGitRepositoryWorktree() as (base_dir, _commit): cmd = grm(["wt", "add", "test", "--track", "origin/test"], cwd=base_dir) assert cmd.returncode == 0 @@ -47,7 +47,7 @@ def test_worktree_clean_refusal_uncommited_changes_new_file(): def test_worktree_clean_refusal_uncommited_changes_changed_file(): - with TempGitRepositoryWorktree() as base_dir: + with TempGitRepositoryWorktree() as (base_dir, _commit): cmd = grm(["wt", "add", "test", "--track", "origin/test"], cwd=base_dir) assert cmd.returncode == 0 @@ -63,7 +63,7 @@ def test_worktree_clean_refusal_uncommited_changes_changed_file(): def test_worktree_clean_refusal_uncommited_changes_cleand_file(): - with TempGitRepositoryWorktree() as base_dir: + with TempGitRepositoryWorktree() as (base_dir, _commit): cmd = grm(["wt", "add", "test", "--track", "origin/test"], cwd=base_dir) assert cmd.returncode == 0 @@ -81,7 +81,7 @@ def test_worktree_clean_refusal_uncommited_changes_cleand_file(): def test_worktree_clean_refusal_commited_changes(): - with TempGitRepositoryWorktree() as base_dir: + with TempGitRepositoryWorktree() as (base_dir, _commit): cmd = grm(["wt", "add", "test", "--track", "origin/test"], cwd=base_dir) assert cmd.returncode == 0 @@ -99,7 +99,7 @@ def test_worktree_clean_refusal_commited_changes(): def test_worktree_clean_refusal_tracking_branch_mismatch(): - with TempGitRepositoryWorktree() as base_dir: + with TempGitRepositoryWorktree() as (base_dir, _commit): cmd = grm(["wt", "add", "test", "--track", "origin/test"], cwd=base_dir) assert cmd.returncode == 0 @@ -117,7 +117,7 @@ def test_worktree_clean_refusal_tracking_branch_mismatch(): def test_worktree_clean_fail_from_subdir(): - with TempGitRepositoryWorktree() as base_dir: + with TempGitRepositoryWorktree() as (base_dir, _commit): cmd = grm(["wt", "add", "test"], cwd=base_dir) assert cmd.returncode == 0 diff --git a/e2e_tests/test_worktree_config_presistent_branch.py b/e2e_tests/test_worktree_config_presistent_branch.py index c8f3f6a..9174b77 100644 --- a/e2e_tests/test_worktree_config_presistent_branch.py +++ b/e2e_tests/test_worktree_config_presistent_branch.py @@ -6,7 +6,7 @@ from helpers import * def test_worktree_never_clean_persistent_branches(): - with TempGitRepositoryWorktree() as base_dir: + with TempGitRepositoryWorktree() as (base_dir, _commit): with open(os.path.join(base_dir, "grm.toml"), "w") as f: f.write( """ @@ -33,7 +33,7 @@ def test_worktree_never_clean_persistent_branches(): def test_worktree_clean_branch_merged_into_persistent(): - with TempGitRepositoryWorktree() as base_dir: + with TempGitRepositoryWorktree() as (base_dir, _commit): with open(os.path.join(base_dir, "grm.toml"), "w") as f: f.write( """ @@ -72,7 +72,7 @@ def test_worktree_clean_branch_merged_into_persistent(): def test_worktree_no_clean_unmerged_branch(): - with TempGitRepositoryWorktree() as base_dir: + with TempGitRepositoryWorktree() as (base_dir, _commit): with open(os.path.join(base_dir, "grm.toml"), "w") as f: f.write( """ @@ -105,7 +105,7 @@ def test_worktree_no_clean_unmerged_branch(): def test_worktree_delete_branch_merged_into_persistent(): - with TempGitRepositoryWorktree() as base_dir: + with TempGitRepositoryWorktree() as (base_dir, _commit): with open(os.path.join(base_dir, "grm.toml"), "w") as f: f.write( """ diff --git a/e2e_tests/test_worktree_conversion.py b/e2e_tests/test_worktree_conversion.py index 47bb16b..33d5312 100644 --- a/e2e_tests/test_worktree_conversion.py +++ b/e2e_tests/test_worktree_conversion.py @@ -23,7 +23,7 @@ def test_convert(): def test_convert_already_worktree(): - with TempGitRepositoryWorktree() as git_dir: + with TempGitRepositoryWorktree() as (git_dir, _commit): before = checksum_directory(git_dir) cmd = grm(["wt", "convert"], cwd=git_dir) diff --git a/e2e_tests/test_worktree_status.py b/e2e_tests/test_worktree_status.py index 86a92d8..d55fa66 100644 --- a/e2e_tests/test_worktree_status.py +++ b/e2e_tests/test_worktree_status.py @@ -4,7 +4,7 @@ from helpers import * def test_worktree_status(): - with TempGitRepositoryWorktree() as base_dir: + with TempGitRepositoryWorktree() as (base_dir, _commit): cmd = grm(["wt", "add", "test"], cwd=base_dir) assert cmd.returncode == 0 @@ -16,7 +16,7 @@ def test_worktree_status(): def test_worktree_status_fail_from_subdir(): - with TempGitRepositoryWorktree() as base_dir: + with TempGitRepositoryWorktree() as (base_dir, _commit): cmd = grm(["wt", "add", "test"], cwd=base_dir) assert cmd.returncode == 0 diff --git a/e2e_tests/test_worktrees.py b/e2e_tests/test_worktrees.py index 0e95659..82516bf 100644 --- a/e2e_tests/test_worktrees.py +++ b/e2e_tests/test_worktrees.py @@ -15,7 +15,7 @@ import os.path def test_worktree_add_simple( remote_branch_already_exists, has_config, has_default, has_prefix ): - with TempGitRepositoryWorktree() as base_dir: + with TempGitRepositoryWorktree() as (base_dir, _commit): if has_config: with open(os.path.join(base_dir, "grm.toml"), "w") as f: f.write( @@ -75,7 +75,7 @@ def test_worktree_add_simple( def test_worktree_add_into_subdirectory(): - with TempGitRepositoryWorktree() as base_dir: + with TempGitRepositoryWorktree() as (base_dir, _commit): cmd = grm(["wt", "add", "dir/test"], cwd=base_dir) assert cmd.returncode == 0 @@ -93,7 +93,7 @@ def test_worktree_add_into_subdirectory(): def test_worktree_add_into_invalid_subdirectory(): - with TempGitRepositoryWorktree() as base_dir: + with TempGitRepositoryWorktree() as (base_dir, _commit): cmd = grm(["wt", "add", "/dir/test"], cwd=base_dir) assert cmd.returncode == 1 assert "dir" not in os.listdir(base_dir) @@ -111,7 +111,7 @@ def test_worktree_add_into_invalid_subdirectory(): def test_worktree_add_with_tracking( remote_branch_already_exists, has_config, has_default, has_prefix ): - with TempGitRepositoryWorktree() as base_dir: + with TempGitRepositoryWorktree() as (base_dir, _commit): if has_config: with open(os.path.join(base_dir, "grm.toml"), "w") as f: f.write( @@ -171,7 +171,7 @@ def test_worktree_add_with_tracking( def test_worktree_add_with_explicit_no_tracking( has_config, has_default, has_prefix, track ): - with TempGitRepositoryWorktree() as base_dir: + with TempGitRepositoryWorktree() as (base_dir, _commit): if has_config: with open(os.path.join(base_dir, "grm.toml"), "w") as f: f.write( @@ -218,7 +218,7 @@ def test_worktree_add_with_explicit_no_tracking( def test_worktree_add_with_config( remote_branch_already_exists, has_default, has_prefix ): - with TempGitRepositoryWorktree() as base_dir: + with TempGitRepositoryWorktree() as (base_dir, _commit): with open(os.path.join(base_dir, "grm.toml"), "w") as f: f.write( f""" @@ -274,7 +274,7 @@ def test_worktree_add_with_config( def test_worktree_delete(): - with TempGitRepositoryWorktree() as base_dir: + with TempGitRepositoryWorktree() as (base_dir, _commit): cmd = grm(["wt", "add", "test", "--track", "origin/test"], cwd=base_dir) assert cmd.returncode == 0 assert "test" in os.listdir(base_dir) @@ -291,7 +291,7 @@ def test_worktree_delete(): def test_worktree_delete_refusal_no_tracking_branch(): - with TempGitRepositoryWorktree() as base_dir: + with TempGitRepositoryWorktree() as (base_dir, _commit): cmd = grm(["wt", "add", "test"], cwd=base_dir) assert cmd.returncode == 0 @@ -307,7 +307,7 @@ def test_worktree_delete_refusal_no_tracking_branch(): def test_worktree_delete_refusal_uncommited_changes_new_file(): - with TempGitRepositoryWorktree() as base_dir: + with TempGitRepositoryWorktree() as (base_dir, _commit): cmd = grm(["wt", "add", "test", "--track", "origin/test"], cwd=base_dir) assert cmd.returncode == 0 @@ -325,7 +325,7 @@ def test_worktree_delete_refusal_uncommited_changes_new_file(): def test_worktree_delete_refusal_uncommited_changes_changed_file(): - with TempGitRepositoryWorktree() as base_dir: + with TempGitRepositoryWorktree() as (base_dir, _commit): cmd = grm(["wt", "add", "test", "--track", "origin/test"], cwd=base_dir) assert cmd.returncode == 0 @@ -343,7 +343,7 @@ def test_worktree_delete_refusal_uncommited_changes_changed_file(): def test_worktree_delete_refusal_uncommited_changes_deleted_file(): - with TempGitRepositoryWorktree() as base_dir: + with TempGitRepositoryWorktree() as (base_dir, _commit): cmd = grm(["wt", "add", "test", "--track", "origin/test"], cwd=base_dir) assert cmd.returncode == 0 @@ -363,7 +363,7 @@ def test_worktree_delete_refusal_uncommited_changes_deleted_file(): def test_worktree_delete_refusal_commited_changes(): - with TempGitRepositoryWorktree() as base_dir: + with TempGitRepositoryWorktree() as (base_dir, _commit): cmd = grm(["wt", "add", "test", "--track", "origin/test"], cwd=base_dir) assert cmd.returncode == 0 @@ -383,7 +383,7 @@ def test_worktree_delete_refusal_commited_changes(): def test_worktree_delete_refusal_tracking_branch_mismatch(): - with TempGitRepositoryWorktree() as base_dir: + with TempGitRepositoryWorktree() as (base_dir, _commit): cmd = grm(["wt", "add", "test", "--track", "origin/test"], cwd=base_dir) assert cmd.returncode == 0 @@ -403,7 +403,7 @@ def test_worktree_delete_refusal_tracking_branch_mismatch(): def test_worktree_delete_force_refusal(): - with TempGitRepositoryWorktree() as base_dir: + with TempGitRepositoryWorktree() as (base_dir, _commit): cmd = grm(["wt", "add", "test"], cwd=base_dir) assert cmd.returncode == 0 @@ -413,7 +413,7 @@ def test_worktree_delete_force_refusal(): def test_worktree_add_delete_add(): - with TempGitRepositoryWorktree() as base_dir: + with TempGitRepositoryWorktree() as (base_dir, _commit): cmd = grm(["wt", "add", "test", "--track", "origin/test"], cwd=base_dir) assert cmd.returncode == 0 assert "test" in os.listdir(base_dir) From 717b0d3a74d22ffa3a5ef7622e9b45526cc140b1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hannes=20K=C3=B6rber?= Date: Wed, 29 Dec 2021 11:19:00 +0100 Subject: [PATCH 44/68] Add fetch & pull option to worktrees --- docs/src/worktrees.md | 33 +++++++ e2e_tests/helpers.py | 1 + e2e_tests/test_worktree_fetch.py | 144 +++++++++++++++++++++++++++ src/grm/cmd.rs | 16 +++ src/grm/main.rs | 52 ++++++++++ src/repo.rs | 161 ++++++++++++++++++++++++++----- 6 files changed, 385 insertions(+), 22 deletions(-) create mode 100644 e2e_tests/test_worktree_fetch.py diff --git a/docs/src/worktrees.md b/docs/src/worktrees.md index baa1f4b..5c2daff 100644 --- a/docs/src/worktrees.md +++ b/docs/src/worktrees.md @@ -279,6 +279,39 @@ Commit them and try again! Afterwards, the directory is empty, as there are no worktrees checked out yet. Now you can use the usual commands to set up worktrees. +### Working with remotes + +To fetch all remote references from all remotes in a worktree setup, you can +use the following command: + +``` +grm wt fetch +[✔] Fetched from all remotes +``` + +This is equivalent to running `git fetch --all` in any of the worktrees. + +Often, you may want to pull all remote changes into your worktrees. For this, +use the `git pull` equivalent: + +``` +grm wt pull +[✔] master: Done +[✔] my-cool-branch: Done +``` + +This will refuse when there are local changes, or if the branch cannot be fast +forwarded. If you want to rebase your local branches, use the `--rebase` switch: + +``` +grm wt pull --rebase +[✔] master: Done +[✔] my-cool-branch: Done +``` + +This will rebase your changes onto the upstream branch. This is mainly helpful +for persistent branches that change on the remote side. + ### Manual access GRM isn't doing any magic, it's just git under the hood. If you need to have access diff --git a/e2e_tests/helpers.py b/e2e_tests/helpers.py index 7ed8137..c4caa42 100644 --- a/e2e_tests/helpers.py +++ b/e2e_tests/helpers.py @@ -169,6 +169,7 @@ class TempGitRepositoryWorktree: git commit -m "commit2" git remote add origin file://{self.remote_1_dir.name} git remote add otherremote file://{self.remote_2_dir.name} + git push origin HEAD:master git ls-files | xargs rm -rf mv .git .git-main-working-tree git --git-dir .git-main-working-tree config core.bare true diff --git a/e2e_tests/test_worktree_fetch.py b/e2e_tests/test_worktree_fetch.py new file mode 100644 index 0000000..1c165a9 --- /dev/null +++ b/e2e_tests/test_worktree_fetch.py @@ -0,0 +1,144 @@ +#!/usr/bin/env python3 + +from helpers import * + +import pytest +import git + + +def test_worktree_fetch(): + with TempGitRepositoryWorktree() as (base_dir, root_commit): + with TempGitFileRemote() as (remote_path, _remote_sha): + shell( + f""" + cd {base_dir} + git --git-dir .git-main-working-tree remote add upstream file://{remote_path} + git --git-dir .git-main-working-tree push --force upstream master:master + """ + ) + + cmd = grm(["wt", "fetch"], cwd=base_dir) + assert cmd.returncode == 0 + + repo = git.Repo(f"{base_dir}/.git-main-working-tree") + assert repo.commit("master").hexsha == repo.commit("origin/master").hexsha + assert repo.commit("master").hexsha == repo.commit("upstream/master").hexsha + + with EmptyDir() as tmp: + shell( + f""" + cd {tmp} + git clone {remote_path} tmp + cd tmp + echo change > mychange-remote + git add mychange-remote + git commit -m "change-remote" + git push origin HEAD:master + """ + ) + remote_commit = git.Repo(f"{tmp}/tmp").commit("master").hexsha + + assert repo.commit("master").hexsha == repo.commit("origin/master").hexsha + assert repo.commit("master").hexsha == repo.commit("upstream/master").hexsha + + cmd = grm(["wt", "fetch"], cwd=base_dir) + assert cmd.returncode == 0 + + assert repo.commit("master").hexsha == repo.commit("origin/master").hexsha + assert repo.commit("master").hexsha == root_commit + assert repo.commit("upstream/master").hexsha == remote_commit + + +@pytest.mark.parametrize("rebase", [True, False]) +@pytest.mark.parametrize("ffable", [True, False]) +def test_worktree_pull(rebase, ffable): + with TempGitRepositoryWorktree() as (base_dir, root_commit): + with TempGitFileRemote() as (remote_path, _remote_sha): + shell( + f""" + cd {base_dir} + git --git-dir .git-main-working-tree remote add upstream file://{remote_path} + git --git-dir .git-main-working-tree push --force upstream master:master + """ + ) + + repo = git.Repo(f"{base_dir}/.git-main-working-tree") + assert repo.commit("origin/master").hexsha == repo.commit("master").hexsha + assert repo.commit("upstream/master").hexsha == repo.commit("master").hexsha + + with EmptyDir() as tmp: + shell( + f""" + cd {tmp} + git clone {remote_path} tmp + cd tmp + git checkout origin/master + echo change > mychange-remote + git add mychange-remote + git commit -m "change-remote" + git push origin HEAD:master + """ + ) + remote_commit = git.Repo(f"{tmp}/tmp").commit("HEAD").hexsha + + grm(["wt", "add", "master", "--track", "upstream/master"], cwd=base_dir) + + repo = git.Repo(f"{base_dir}/master") + if not ffable: + shell( + f""" + cd {base_dir}/master + echo change > mychange + git add mychange + git commit -m "local-commit-in-master" + """ + ) + + args = ["wt", "pull"] + if rebase: + args += ["--rebase"] + cmd = grm(args, cwd=base_dir) + assert cmd.returncode == 0 + + assert repo.commit("upstream/master").hexsha == remote_commit + assert repo.commit("origin/master").hexsha == root_commit + assert ( + repo.commit("master").hexsha != repo.commit("origin/master").hexsha + ) + + if not rebase: + if ffable: + assert ( + repo.commit("master").hexsha + != repo.commit("origin/master").hexsha + ) + assert ( + repo.commit("master").hexsha + == repo.commit("upstream/master").hexsha + ) + assert repo.commit("upstream/master").hexsha == remote_commit + else: + assert "cannot be fast forwarded" in cmd.stderr + assert ( + repo.commit("master").hexsha + != repo.commit("origin/master").hexsha + ) + assert repo.commit("master").hexsha != remote_commit + assert repo.commit("upstream/master").hexsha == remote_commit + else: + if ffable: + assert ( + repo.commit("master").hexsha + != repo.commit("origin/master").hexsha + ) + assert ( + repo.commit("master").hexsha + == repo.commit("upstream/master").hexsha + ) + assert repo.commit("upstream/master").hexsha == remote_commit + else: + assert ( + repo.commit("master").message.strip() + == "local-commit-in-master" + ) + assert repo.commit("master~1").hexsha == remote_commit diff --git a/src/grm/cmd.rs b/src/grm/cmd.rs index 67b00b4..5d247c7 100644 --- a/src/grm/cmd.rs +++ b/src/grm/cmd.rs @@ -87,6 +87,10 @@ pub enum WorktreeAction { Convert(WorktreeConvertArgs), #[clap(about = "Clean all worktrees that do not contain uncommited/unpushed changes")] Clean(WorktreeCleanArgs), + #[clap(about = "Fetch refs from remotes")] + Fetch(WorktreeFetchArgs), + #[clap(about = "Fetch refs from remotes and update local branches")] + Pull(WorktreePullArgs), } #[derive(Parser)] @@ -121,6 +125,18 @@ pub struct WorktreeConvertArgs {} #[derive(Parser)] pub struct WorktreeCleanArgs {} +#[derive(Parser)] +pub struct WorktreeFetchArgs {} + +#[derive(Parser)] +pub struct WorktreePullArgs { + #[clap( + long = "--rebase", + about = "Perform a rebase instead of a fast-forward" + )] + pub rebase: bool, +} + pub fn parse() -> Opts { Opts::parse() } diff --git a/src/grm/main.rs b/src/grm/main.rs index dea98a3..20d33a6 100644 --- a/src/grm/main.rs +++ b/src/grm/main.rs @@ -310,6 +310,58 @@ fn main() { )); } } + cmd::WorktreeAction::Fetch(_args) => { + let repo = grm::Repo::open(&cwd, true).unwrap_or_else(|error| { + if error.kind == grm::RepoErrorKind::NotFound { + print_error("Directory does not contain a git repository"); + } else { + print_error(&format!("Opening repository failed: {}", error)); + } + process::exit(1); + }); + + repo.fetchall().unwrap_or_else(|error| { + print_error(&format!("Error fetching remotes: {}", error)); + process::exit(1); + }); + print_success("Fetched from all remotes"); + } + cmd::WorktreeAction::Pull(args) => { + let repo = grm::Repo::open(&cwd, true).unwrap_or_else(|error| { + if error.kind == grm::RepoErrorKind::NotFound { + print_error("Directory does not contain a git repository"); + } else { + print_error(&format!("Opening repository failed: {}", error)); + } + process::exit(1); + }); + + repo.fetchall().unwrap_or_else(|error| { + print_error(&format!("Error fetching remotes: {}", error)); + process::exit(1); + }); + + for worktree in repo.get_worktrees().unwrap_or_else(|error| { + print_error(&format!("Error getting worktrees: {}", error)); + process::exit(1); + }) { + if let Some(warning) = + worktree + .forward_branch(args.rebase) + .unwrap_or_else(|error| { + print_error(&format!( + "Error updating worktree branch: {}", + error + )); + process::exit(1); + }) + { + print_warning(&format!("{}: {}", worktree.name(), warning)); + } else { + print_success(&format!("{}: Done", worktree.name())); + } + } + } } } } diff --git a/src/repo.rs b/src/repo.rs index d66f8e7..e6cd49c 100644 --- a/src/repo.rs +++ b/src/repo.rs @@ -180,6 +180,77 @@ impl Worktree { pub fn name(&self) -> &str { &self.name } + + pub fn forward_branch(&self, rebase: bool) -> Result, String> { + let repo = Repo::open(Path::new(&self.name), false) + .map_err(|error| format!("Error opening worktree: {}", error))?; + + if let Ok(remote_branch) = repo.find_local_branch(&self.name)?.upstream() { + let status = repo.status(false)?; + + if !status.clean() { + return Ok(Some(String::from("Worktree contains changes"))); + } + + let remote_annotated_commit = repo + .0 + .find_annotated_commit(remote_branch.commit()?.id().0) + .map_err(convert_libgit2_error)?; + + if rebase { + let mut rebase = repo + .0 + .rebase( + None, // use HEAD + Some(&remote_annotated_commit), + None, // figure out the base yourself, libgit2! + Some(&mut git2::RebaseOptions::new()), + ) + .map_err(convert_libgit2_error)?; + + while let Some(operation) = rebase.next() { + let operation = operation.map_err(convert_libgit2_error)?; + + // This is required to preserve the commiter of the rebased + // commits, which is the expected behaviour. + let rebased_commit = repo + .0 + .find_commit(operation.id()) + .map_err(convert_libgit2_error)?; + let committer = rebased_commit.committer(); + + if rebase.commit(None, &committer, None).is_err() { + rebase.abort().map_err(convert_libgit2_error)?; + } + } + + rebase.finish(None).map_err(convert_libgit2_error)?; + } else { + let (analysis, _preference) = repo + .0 + .merge_analysis(&[&remote_annotated_commit]) + .map_err(convert_libgit2_error)?; + + if analysis.is_up_to_date() { + return Ok(None); + } + if !analysis.is_fast_forward() { + return Ok(Some(String::from("Worktree cannot be fast forwarded"))); + } + + repo.0 + .reset( + remote_branch.commit()?.0.as_object(), + git2::ResetType::Hard, + Some(git2::build::CheckoutBuilder::new().safe()), + ) + .map_err(convert_libgit2_error)?; + } + } else { + return Ok(Some(String::from("No remote branch to rebase onto"))); + }; + Ok(None) + } } #[cfg(test)] @@ -364,6 +435,42 @@ impl Repo { Ok(()) } + pub fn fetchall(&self) -> Result<(), String> { + for remote in self.remotes()? { + self.fetch(&remote)?; + } + Ok(()) + } + + pub fn local_branches(&self) -> Result, String> { + self.0 + .branches(Some(git2::BranchType::Local)) + .map_err(convert_libgit2_error)? + .map(|branch| Ok(Branch(branch.map_err(convert_libgit2_error)?.0))) + .collect::, String>>() + } + + pub fn fetch(&self, remote_name: &str) -> Result<(), String> { + let mut remote = self + .0 + .find_remote(remote_name) + .map_err(convert_libgit2_error)?; + + let mut fetch_options = git2::FetchOptions::new(); + fetch_options.remote_callbacks(get_remote_callbacks()); + + for refspec in &remote.fetch_refspecs().map_err(convert_libgit2_error)? { + remote + .fetch( + &[refspec.ok_or("Remote name is invalid utf-8")?], + Some(&mut fetch_options), + None, + ) + .map_err(convert_libgit2_error)?; + } + Ok(()) + } + pub fn init(path: &Path, is_worktree: bool) -> Result { let repo = match is_worktree { false => Repository::init(path).map_err(convert_libgit2_error)?, @@ -1090,6 +1197,37 @@ impl Branch<'_> { } } +fn get_remote_callbacks() -> git2::RemoteCallbacks<'static> { + let mut callbacks = git2::RemoteCallbacks::new(); + callbacks.push_update_reference(|_, status| { + if let Some(message) = status { + return Err(git2::Error::new( + git2::ErrorCode::GenericError, + git2::ErrorClass::None, + message, + )); + } + Ok(()) + }); + callbacks.credentials(|_url, username_from_url, _allowed_types| { + let username = match username_from_url { + Some(username) => username, + None => panic!("Could not get username. This is a bug"), + }; + git2::Cred::ssh_key_from_agent(username) + }); + + callbacks.credentials(|_url, username_from_url, _allowed_types| { + let username = match username_from_url { + Some(username) => username, + None => panic!("Could not get username. This is a bug"), + }; + git2::Cred::ssh_key(username, None, &crate::env_home().join(".ssh/id_rsa"), None) + }); + + callbacks +} + impl RemoteHandle<'_> { pub fn url(&self) -> String { self.0 @@ -1121,29 +1259,8 @@ impl RemoteHandle<'_> { return Err(String::from("Trying to push to a non-pushable remote")); } - let mut callbacks = git2::RemoteCallbacks::new(); - callbacks.push_update_reference(|_, status| { - if let Some(message) = status { - return Err(git2::Error::new( - git2::ErrorCode::GenericError, - git2::ErrorClass::None, - message, - )); - } - Ok(()) - }); - callbacks.credentials(|_url, username_from_url, _allowed_types| { - let username = match username_from_url { - Some(username) => username, - None => panic!("Could not get username. This is a bug"), - }; - git2::Cred::ssh_key_from_agent(username).or_else(|_| { - git2::Cred::ssh_key(username, None, &crate::env_home().join(".ssh/id_rsa"), None) - }) - }); - let mut push_options = git2::PushOptions::new(); - push_options.remote_callbacks(callbacks); + push_options.remote_callbacks(get_remote_callbacks()); let push_refspec = format!( "+refs/heads/{}:refs/heads/{}", From c0168c36504180d94417544ee3c1bc1e32530c70 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hannes=20K=C3=B6rber?= Date: Wed, 29 Dec 2021 18:43:14 +0100 Subject: [PATCH 45/68] Add helper function on RepoStatus to check clean state --- src/repo.rs | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/src/repo.rs b/src/repo.rs index e6cd49c..35f8d79 100644 --- a/src/repo.rs +++ b/src/repo.rs @@ -253,6 +253,17 @@ impl Worktree { } } +impl RepoStatus { + fn clean(&self) -> bool { + match &self.changes { + None => true, + Some(changes) => { + changes.files_new == 0 && changes.files_deleted == 0 && changes.files_modified == 0 + } + } + } +} + #[cfg(test)] mod tests { use super::*; From fc91ee7a01afdf2c9095789625ced7664ccf752d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hannes=20K=C3=B6rber?= Date: Wed, 29 Dec 2021 20:44:09 +0100 Subject: [PATCH 46/68] Just: Allow running selected e2e tests --- Justfile | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Justfile b/Justfile index 8f3dc66..c87684f 100644 --- a/Justfile +++ b/Justfile @@ -30,10 +30,10 @@ e2e-venv: && pip --disable-pip-version-check install -r ./requirements.txt >/dev/null -test-e2e: e2e-venv release +test-e2e +tests=".": e2e-venv release cd ./e2e_tests \ && . ./venv/bin/activate \ - && TMPDIR=/dev/shm python -m pytest --color=yes . + && TMPDIR=/dev/shm python -m pytest --color=yes {{tests}} update-dependencies: @cd ./depcheck \ From 9f6c84d78c37fb08b247531e476e4a65532817c8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hannes=20K=C3=B6rber?= Date: Fri, 31 Dec 2021 10:53:16 +0100 Subject: [PATCH 47/68] e2e-tests: Make commit messages more obvious --- e2e_tests/helpers.py | 30 +++++++++++++++--------------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/e2e_tests/helpers.py b/e2e_tests/helpers.py index c4caa42..0cb3882 100644 --- a/e2e_tests/helpers.py +++ b/e2e_tests/helpers.py @@ -122,9 +122,9 @@ class TempGitRepository: f""" cd {self.tmpdir.name} git init - echo test > test - git add test - git commit -m "commit1" + echo test > root-commit + git add root-commit + git commit -m "root-commit" git remote add origin file://{self.remote_1_dir.name} git remote add otherremote file://{self.remote_2_dir.name} """ @@ -161,12 +161,12 @@ class TempGitRepositoryWorktree: f""" cd {self.tmpdir.name} git init - echo test > test - git add test - git commit -m "commit1" - echo test > test2 - git add test2 - git commit -m "commit2" + echo test > root-commit-in-worktree-1 + git add root-commit-in-worktree-1 + git commit -m "root-commit-in-worktree-1" + echo test > root-commit-in-worktree-2 + git add root-commit-in-worktree-2 + git commit -m "root-commit-in-worktree-2" git remote add origin file://{self.remote_1_dir.name} git remote add otherremote file://{self.remote_2_dir.name} git push origin HEAD:master @@ -228,12 +228,12 @@ class TempGitFileRemote: f""" cd {self.tmpdir.name} git init - echo test > test - git add test - git commit -m "commit1" - echo test > test2 - git add test2 - git commit -m "commit2" + echo test > root-commit-in-remote-1 + git add root-commit-in-remote-1 + git commit -m "root-commit-in-remote-1" + echo test > root-commit-in-remote-2 + git add root-commit-in-remote-2 + git commit -m "root-commit-in-remote-2" git ls-files | xargs rm -rf mv .git/* . git config core.bare true From 76130c5b4866a5f86839b7c9ede92a3932c4d0a1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hannes=20K=C3=B6rber?= Date: Fri, 31 Dec 2021 11:14:34 +0100 Subject: [PATCH 48/68] Add script to update pip requirements.txt --- Justfile | 11 ++++++++++- e2e_tests/update_requirementstxt.sh | 18 ++++++++++++++++++ 2 files changed, 28 insertions(+), 1 deletion(-) create mode 100755 e2e_tests/update_requirementstxt.sh diff --git a/Justfile b/Justfile index c87684f..dce27d2 100644 --- a/Justfile +++ b/Justfile @@ -1,4 +1,4 @@ -check: check-cargo-lock test +check: check-cargo-lock check-pip-requirements test cargo check cargo fmt --check cargo clippy --no-deps -- -Dwarnings @@ -41,3 +41,12 @@ update-dependencies: && . ./venv/bin/activate \ && pip --disable-pip-version-check install -r ./requirements.txt > /dev/null \ && ./update-cargo-dependencies.py + +update-pip-requirements: e2e-venv + @cd ./e2e_tests \ + && ./update_requirementstxt.sh + +check-pip-requirements: e2e-venv + @cd ./e2e_tests \ + && . ./venv/bin/activate \ + && pip list --outdated | grep -q '.' && exit 1 || exit 0 diff --git a/e2e_tests/update_requirementstxt.sh b/e2e_tests/update_requirementstxt.sh new file mode 100755 index 0000000..9b596ba --- /dev/null +++ b/e2e_tests/update_requirementstxt.sh @@ -0,0 +1,18 @@ +#!/usr/bin/env bash + +set -o nounset +set -o errexit + +# shellcheck disable=SC1091 +source ./venv/bin/activate + +pip --disable-pip-version-check install -r ./requirements.txt + +pip3 list --outdated --format=freeze | grep -v '^\-e' | cut -d = -f 1 | while read -r package ; do + pip install --upgrade "${package}" + version="$(pip show "${package}" | grep '^Version' | cut -d ' ' -f 2)" + message="e2e_tests/pip: Update ${package} to ${version}" + pip freeze > requirements.txt + git add ./requirements.txt + git commit --message "${message}" +done From 0fb9a22d4741424e01e101017eae3cbb327dfcf5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hannes=20K=C3=B6rber?= Date: Fri, 31 Dec 2021 11:13:44 +0100 Subject: [PATCH 49/68] e2e_tests/pip: Update attrs to 21.4.0 --- e2e_tests/requirements.txt | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/e2e_tests/requirements.txt b/e2e_tests/requirements.txt index dc4e0b7..a5df797 100644 --- a/e2e_tests/requirements.txt +++ b/e2e_tests/requirements.txt @@ -1,12 +1,13 @@ -attrs==21.2.0 +attrs==21.4.0 gitdb==4.0.9 GitPython==3.1.24 iniconfig==1.1.1 packaging==21.3 +pkg_resources==0.0.0 pluggy==1.0.0 py==1.11.0 pyparsing==3.0.6 pytest==6.2.5 smmap==5.0.0 toml==0.10.2 -typing-extensions==4.0.0 +typing_extensions==4.0.0 From a4e993b7fca8755cf19bd9ac5b950ec95ae96082 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hannes=20K=C3=B6rber?= Date: Fri, 31 Dec 2021 11:13:46 +0100 Subject: [PATCH 50/68] e2e_tests/pip: Update typing_extensions to 4.0.1 --- e2e_tests/requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/e2e_tests/requirements.txt b/e2e_tests/requirements.txt index a5df797..ee441bc 100644 --- a/e2e_tests/requirements.txt +++ b/e2e_tests/requirements.txt @@ -10,4 +10,4 @@ pyparsing==3.0.6 pytest==6.2.5 smmap==5.0.0 toml==0.10.2 -typing_extensions==4.0.0 +typing_extensions==4.0.1 From 2f6405ea10ce1a5732c7c438c4e37290bbf58e34 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hannes=20K=C3=B6rber?= Date: Mon, 3 Jan 2022 10:07:14 +0100 Subject: [PATCH 51/68] dependencies: Update serde to 1.0.133 --- Cargo.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Cargo.toml b/Cargo.toml index 2fa74e9..2f39628 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -40,7 +40,7 @@ path = "src/grm/main.rs" version = "=0.5.8" [dependencies.serde] -version = "=1.0.132" +version = "=1.0.133" features = ["derive"] [dependencies.git2] From 0485facf33657aeacb8583d161f685af7d81e7ad Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hannes=20K=C3=B6rber?= Date: Mon, 3 Jan 2022 10:08:07 +0100 Subject: [PATCH 52/68] Cargo.lock: Updating serde v1.0.132 -> v1.0.133 --- Cargo.lock | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 9b079b0..8a9565e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -595,18 +595,18 @@ checksum = "d29ab0c6d3fc0ee92fe66e2d99f700eab17a8d57d1c1d3b748380fb20baa78cd" [[package]] name = "serde" -version = "1.0.132" +version = "1.0.133" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b9875c23cf305cd1fd7eb77234cbb705f21ea6a72c637a5c6db5fe4b8e7f008" +checksum = "97565067517b60e2d1ea8b268e59ce036de907ac523ad83a0475da04e818989a" dependencies = [ "serde_derive", ] [[package]] name = "serde_derive" -version = "1.0.132" +version = "1.0.133" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ecc0db5cb2556c0e558887d9bbdcf6ac4471e83ff66cf696e5419024d1606276" +checksum = "ed201699328568d8d08208fdd080e3ff594e6c422e438b6705905da01005d537" dependencies = [ "proc-macro2", "quote", From 599973e10a61c8879c3d034e651cf7ab1321ed56 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hannes=20K=C3=B6rber?= Date: Mon, 3 Jan 2022 10:09:44 +0100 Subject: [PATCH 53/68] depcheck: Do index update before any other operations --- depcheck/update-cargo-dependencies.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/depcheck/update-cargo-dependencies.py b/depcheck/update-cargo-dependencies.py index 6e8adae..f836b14 100755 --- a/depcheck/update-cargo-dependencies.py +++ b/depcheck/update-cargo-dependencies.py @@ -33,6 +33,13 @@ with open("../Cargo.toml", "r") as cargo_config: update_necessary = False +# This updates the crates.io index, see https://github.com/rust-lang/cargo/issues/3377 +subprocess.run( + ["cargo", "search", "--limit", "0"], + check=True, + capture_output=False, # to get some git output +) + for tier in ["dependencies", "dev-dependencies"]: for name, dependency in cargo[tier].items(): version = dependency["version"].lstrip("=") @@ -85,13 +92,6 @@ for tier in ["dependencies", "dev-dependencies"]: ) -# This updates the crates.io index, see https://github.com/rust-lang/cargo/issues/3377 -subprocess.run( - ["cargo", "search", "--limit", "0"], - check=True, - capture_output=False, # to get some git output -) - # Note that we have to restart this lookup every time, as later packages can depend # on former packages while True: From 7a2fa7ae3fcad1d0c14bfbad2126302aa676b127 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hannes=20K=C3=B6rber?= Date: Mon, 3 Jan 2022 10:09:55 +0100 Subject: [PATCH 54/68] Add justfile target to update all dependencies --- Justfile | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/Justfile b/Justfile index dce27d2..3f97304 100644 --- a/Justfile +++ b/Justfile @@ -35,7 +35,9 @@ test-e2e +tests=".": e2e-venv release && . ./venv/bin/activate \ && TMPDIR=/dev/shm python -m pytest --color=yes {{tests}} -update-dependencies: +update-dependencies: update-cargo-dependencies update-pip-requirements + +update-cargo-dependencies: @cd ./depcheck \ && python3 -m venv ./venv \ && . ./venv/bin/activate \ From ef8a57c60e4ecc63c9ad56bd4d67de3eb3662d13 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hannes=20K=C3=B6rber?= Date: Wed, 29 Dec 2021 19:02:42 +0100 Subject: [PATCH 55/68] Add rebase option for worktrees --- docs/src/worktrees.md | 34 ++++ e2e_tests/test_worktree_rebase.py | 250 ++++++++++++++++++++++++++++++ src/grm/cmd.rs | 10 ++ src/grm/main.rs | 70 +++++++++ src/repo.rs | 82 +++++++++- 5 files changed, 445 insertions(+), 1 deletion(-) create mode 100644 e2e_tests/test_worktree_rebase.py diff --git a/docs/src/worktrees.md b/docs/src/worktrees.md index 5c2daff..847cdad 100644 --- a/docs/src/worktrees.md +++ b/docs/src/worktrees.md @@ -312,6 +312,40 @@ grm wt pull --rebase This will rebase your changes onto the upstream branch. This is mainly helpful for persistent branches that change on the remote side. +There is a similar rebase feature that rebases onto the **default** branch instead: + +``` +grm wt rebase +[✔] master: Done +[✔] my-cool-branch: Done +``` + +This is super helpful for feature branches. If you want to incorporate changes +made on the remote branches, use `grm wt rebase` and all your branches will +be up to date. If you want to also update to remote tracking branches in one go, +use the `--pull` flag, and `--rebase` if you want to rebase instead of aborting +on non-fast-forwards: + +``` +grm wt rebase --pull --rebase +[✔] master: Done +[✔] my-cool-branch: Done +``` + +"So, what's the difference between `pull --rebase` and `rebase --pull`? Why the +hell is there a `--rebase` flag in the `rebase` command?" + +Yes, it's kind of weird. Remember that `pull` only ever updates each worktree +to their remote branch, if possible. `rebase` rabases onto the **default** branch +instead. The switches to `rebase` are just convenience, so you do not have to +run two commands. + +* `rebase --pull` is the same as `pull` && `rebase` +* `rebase --pull --rebase` is the same as `pull --rebase` && `rebase` + +I understand that the UX is not the most intuitive. If you can think of an +improvement, please let me know (e.g. via an GitHub issue)! + ### Manual access GRM isn't doing any magic, it's just git under the hood. If you need to have access diff --git a/e2e_tests/test_worktree_rebase.py b/e2e_tests/test_worktree_rebase.py new file mode 100644 index 0000000..c913aaf --- /dev/null +++ b/e2e_tests/test_worktree_rebase.py @@ -0,0 +1,250 @@ +#!/usr/bin/env python3 + +from helpers import * + +import pytest + +import git + + +@pytest.mark.parametrize("pull", [True, False]) +@pytest.mark.parametrize("rebase", [True, False]) +@pytest.mark.parametrize("ffable", [True, False]) +def test_worktree_rebase(pull, rebase, ffable): + with TempGitRepositoryWorktree() as (base_dir, _root_commit): + with open(os.path.join(base_dir, "grm.toml"), "w") as f: + f.write('persistent_branches = ["mybasebranch"]') + + repo = git.Repo(f"{base_dir}/.git-main-working-tree") + + grm( + ["wt", "add", "mybasebranch", "--track", "origin/mybasebranch"], + cwd=base_dir, + ) + + shell( + f""" + cd {base_dir}/mybasebranch + echo change > mychange-root + git add mychange-root + git commit -m "commit-root" + echo change > mychange-base-local + git add mychange-base-local + git commit -m "commit-in-base-local" + git push origin mybasebranch + """ + ) + + grm( + ["wt", "add", "myfeatbranch", "--track", "origin/myfeatbranch"], + cwd=base_dir, + ) + shell( + f""" + cd {base_dir}/myfeatbranch + git reset --hard mybasebranch^ # root + echo change > mychange-feat-local + git add mychange-feat-local + git commit -m "commit-in-feat-local" + git push origin HEAD:myfeatbranch + """ + ) + + grm(["wt", "add", "tmp"], cwd=base_dir) + shell( + f""" + cd {base_dir}/tmp + git reset --hard mybasebranch + echo change > mychange-base-remote + git add mychange-base-remote + git commit -m "commit-in-base-remote" + git push origin HEAD:mybasebranch + + git reset --hard myfeatbranch + echo change > mychange-feat-remote + git add mychange-feat-remote + git commit -m "commit-in-feat-remote" + git push origin HEAD:myfeatbranch + """ + ) + + if not ffable: + shell( + f""" + cd {base_dir}/mybasebranch + echo change > mychange-base-no-ff + git add mychange-base-no-ff + git commit -m "commit-in-base-local-no-ff" + + cd {base_dir}/myfeatbranch + echo change > mychange-feat-no-ff + git add mychange-feat-no-ff + git commit -m "commit-in-feat-local-no-ff" + """ + ) + + grm(["wt", "delete", "--force", "tmp"], cwd=base_dir) + + repo = git.Repo(f"{base_dir}/.git-main-working-tree") + if ffable: + assert repo.commit("mybasebranch~1").message.strip() == "commit-root" + assert ( + repo.refs.mybasebranch.commit.message.strip() == "commit-in-base-local" + ) + assert ( + repo.remote("origin").refs.mybasebranch.commit.message.strip() + == "commit-in-base-remote" + ) + assert ( + repo.refs.myfeatbranch.commit.message.strip() == "commit-in-feat-local" + ) + assert ( + repo.remote("origin").refs.myfeatbranch.commit.message.strip() + == "commit-in-feat-remote" + ) + else: + assert ( + repo.commit("mybasebranch").message.strip() + == "commit-in-base-local-no-ff" + ) + assert ( + repo.commit("mybasebranch~1").message.strip() == "commit-in-base-local" + ) + assert repo.commit("mybasebranch~2").message.strip() == "commit-root" + assert ( + repo.commit("myfeatbranch").message.strip() + == "commit-in-feat-local-no-ff" + ) + assert ( + repo.commit("myfeatbranch~1").message.strip() == "commit-in-feat-local" + ) + assert repo.commit("myfeatbranch~2").message.strip() == "commit-root" + assert ( + repo.remote("origin").refs.mybasebranch.commit.message.strip() + == "commit-in-base-remote" + ) + assert ( + repo.remote("origin").refs.myfeatbranch.commit.message.strip() + == "commit-in-feat-remote" + ) + + args = ["wt", "rebase"] + if pull: + args += ["--pull"] + if rebase: + args += ["--rebase"] + cmd = grm(args, cwd=base_dir) + + print(args) + if rebase and not pull: + assert cmd.returncode != 0 + assert len(cmd.stderr) != 0 + else: + assert cmd.returncode == 0 + repo = git.Repo(f"{base_dir}/myfeatbranch") + if pull: + if rebase: + if ffable: + assert ( + repo.commit("HEAD").message.strip() + == "commit-in-feat-remote" + ) + assert ( + repo.commit("HEAD~1").message.strip() + == "commit-in-feat-local" + ) + assert ( + repo.commit("HEAD~2").message.strip() + == "commit-in-base-remote" + ) + assert ( + repo.commit("HEAD~3").message.strip() + == "commit-in-base-local" + ) + assert repo.commit("HEAD~4").message.strip() == "commit-root" + else: + assert ( + repo.commit("HEAD").message.strip() + == "commit-in-feat-local-no-ff" + ) + assert ( + repo.commit("HEAD~1").message.strip() + == "commit-in-feat-remote" + ) + assert ( + repo.commit("HEAD~2").message.strip() + == "commit-in-feat-local" + ) + assert ( + repo.commit("HEAD~3").message.strip() + == "commit-in-base-local-no-ff" + ) + assert ( + repo.commit("HEAD~4").message.strip() + == "commit-in-base-remote" + ) + assert ( + repo.commit("HEAD~5").message.strip() + == "commit-in-base-local" + ) + assert repo.commit("HEAD~6").message.strip() == "commit-root" + else: + if ffable: + assert ( + repo.commit("HEAD").message.strip() + == "commit-in-feat-remote" + ) + assert ( + repo.commit("HEAD~1").message.strip() + == "commit-in-feat-local" + ) + assert ( + repo.commit("HEAD~2").message.strip() + == "commit-in-base-remote" + ) + assert ( + repo.commit("HEAD~3").message.strip() + == "commit-in-base-local" + ) + assert repo.commit("HEAD~4").message.strip() == "commit-root" + else: + assert ( + repo.commit("HEAD").message.strip() + == "commit-in-feat-local-no-ff" + ) + assert ( + repo.commit("HEAD~1").message.strip() + == "commit-in-feat-local" + ) + assert ( + repo.commit("HEAD~2").message.strip() + == "commit-in-base-local-no-ff" + ) + assert ( + repo.commit("HEAD~3").message.strip() + == "commit-in-base-local" + ) + assert repo.commit("HEAD~4").message.strip() == "commit-root" + else: + if ffable: + assert repo.commit("HEAD").message.strip() == "commit-in-feat-local" + assert ( + repo.commit("HEAD~1").message.strip() == "commit-in-base-local" + ) + assert repo.commit("HEAD~2").message.strip() == "commit-root" + else: + assert ( + repo.commit("HEAD").message.strip() + == "commit-in-feat-local-no-ff" + ) + assert ( + repo.commit("HEAD~1").message.strip() == "commit-in-feat-local" + ) + assert ( + repo.commit("HEAD~2").message.strip() + == "commit-in-base-local-no-ff" + ) + assert ( + repo.commit("HEAD~3").message.strip() == "commit-in-base-local" + ) + assert repo.commit("HEAD~4").message.strip() == "commit-root" diff --git a/src/grm/cmd.rs b/src/grm/cmd.rs index 5d247c7..017357b 100644 --- a/src/grm/cmd.rs +++ b/src/grm/cmd.rs @@ -91,6 +91,8 @@ pub enum WorktreeAction { Fetch(WorktreeFetchArgs), #[clap(about = "Fetch refs from remotes and update local branches")] Pull(WorktreePullArgs), + #[clap(about = "Rebase worktree onto default branch")] + Rebase(WorktreeRebaseArgs), } #[derive(Parser)] @@ -137,6 +139,14 @@ pub struct WorktreePullArgs { pub rebase: bool, } +#[derive(Parser)] +pub struct WorktreeRebaseArgs { + #[clap(long = "--pull", about = "Perform a pull before rebasing")] + pub pull: bool, + #[clap(long = "--rebase", about = "Perform a rebase when doing a pull")] + pub rebase: bool, +} + pub fn parse() -> Opts { Opts::parse() } diff --git a/src/grm/main.rs b/src/grm/main.rs index 20d33a6..d54f45c 100644 --- a/src/grm/main.rs +++ b/src/grm/main.rs @@ -362,6 +362,76 @@ fn main() { } } } + cmd::WorktreeAction::Rebase(args) => { + if args.rebase && !args.pull { + print_error("There is no point in using --rebase without --pull"); + process::exit(1); + } + let repo = grm::Repo::open(&cwd, true).unwrap_or_else(|error| { + if error.kind == grm::RepoErrorKind::NotFound { + print_error("Directory does not contain a git repository"); + } else { + print_error(&format!("Opening repository failed: {}", error)); + } + process::exit(1); + }); + + if args.pull { + repo.fetchall().unwrap_or_else(|error| { + print_error(&format!("Error fetching remotes: {}", error)); + process::exit(1); + }); + } + + let config = + grm::repo::read_worktree_root_config(&cwd).unwrap_or_else(|error| { + print_error(&format!( + "Failed to read worktree configuration: {}", + error + )); + process::exit(1); + }); + + let worktrees = repo.get_worktrees().unwrap_or_else(|error| { + print_error(&format!("Error getting worktrees: {}", error)); + process::exit(1); + }); + + for worktree in &worktrees { + if args.pull { + if let Some(warning) = worktree + .forward_branch(args.rebase) + .unwrap_or_else(|error| { + print_error(&format!( + "Error updating worktree branch: {}", + error + )); + process::exit(1); + }) + { + print_warning(&format!("{}: {}", worktree.name(), warning)); + } + } + } + + for worktree in &worktrees { + if let Some(warning) = + worktree + .rebase_onto_default(&config) + .unwrap_or_else(|error| { + print_error(&format!( + "Error rebasing worktree branch: {}", + error + )); + process::exit(1); + }) + { + print_warning(&format!("{}: {}", worktree.name(), warning)); + } else { + print_success(&format!("{}: Done", worktree.name())); + } + } + } } } } diff --git a/src/repo.rs b/src/repo.rs index 35f8d79..91a5b01 100644 --- a/src/repo.rs +++ b/src/repo.rs @@ -219,8 +219,16 @@ impl Worktree { .map_err(convert_libgit2_error)?; let committer = rebased_commit.committer(); - if rebase.commit(None, &committer, None).is_err() { + // This is effectively adding all files to the index explicitly. + // Normal files are already staged, but changed submodules are not. + let mut index = repo.0.index().map_err(convert_libgit2_error)?; + index + .add_all(["."].iter(), git2::IndexAddOption::CHECK_PATHSPEC, None) + .map_err(convert_libgit2_error)?; + + if let Err(error) = rebase.commit(None, &committer, None) { rebase.abort().map_err(convert_libgit2_error)?; + return Err(convert_libgit2_error(error)); } } @@ -251,6 +259,78 @@ impl Worktree { }; Ok(None) } + + pub fn rebase_onto_default( + &self, + config: &Option, + ) -> Result, String> { + let repo = Repo::open(Path::new(&self.name), false) + .map_err(|error| format!("Error opening worktree: {}", error))?; + + let guess_default_branch = || { + repo.default_branch() + .map_err(|_| "Could not determine default branch")? + .name() + .map_err(|error| format!("Failed getting default branch name: {}", error)) + }; + + let default_branch_name = match &config { + None => guess_default_branch()?, + Some(config) => match &config.persistent_branches { + None => guess_default_branch()?, + Some(persistent_branches) => { + if persistent_branches.is_empty() { + guess_default_branch()? + } else { + persistent_branches[0].clone() + } + } + }, + }; + + let base_branch = repo.find_local_branch(&default_branch_name)?; + let base_annotated_commit = repo + .0 + .find_annotated_commit(base_branch.commit()?.id().0) + .map_err(convert_libgit2_error)?; + + let mut rebase = repo + .0 + .rebase( + None, // use HEAD + Some(&base_annotated_commit), + None, // figure out the base yourself, libgit2! + Some(&mut git2::RebaseOptions::new()), + ) + .map_err(convert_libgit2_error)?; + + while let Some(operation) = rebase.next() { + let operation = operation.map_err(convert_libgit2_error)?; + + // This is required to preserve the commiter of the rebased + // commits, which is the expected behaviour. + let rebased_commit = repo + .0 + .find_commit(operation.id()) + .map_err(convert_libgit2_error)?; + let committer = rebased_commit.committer(); + + // This is effectively adding all files to the index explicitly. + // Normal files are already staged, but changed submodules are not. + let mut index = repo.0.index().map_err(convert_libgit2_error)?; + index + .add_all(["."].iter(), git2::IndexAddOption::CHECK_PATHSPEC, None) + .map_err(convert_libgit2_error)?; + + if let Err(error) = rebase.commit(None, &committer, None) { + rebase.abort().map_err(convert_libgit2_error)?; + return Err(convert_libgit2_error(error)); + } + } + + rebase.finish(None).map_err(convert_libgit2_error)?; + Ok(None) + } } impl RepoStatus { From a94bd19362cbe416965c44d2988a38badffb143d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hannes=20K=C3=B6rber?= Date: Tue, 4 Jan 2022 11:55:00 +0100 Subject: [PATCH 56/68] repos: Set error code correctly when status fails --- src/grm/main.rs | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/src/grm/main.rs b/src/grm/main.rs index d54f45c..9a2996a 100644 --- a/src/grm/main.rs +++ b/src/grm/main.rs @@ -50,7 +50,10 @@ fn main() { print_error(&format!("Error: {}", error)); } } - Err(error) => print_error(&format!("Error getting status: {}", error)), + Err(error) => { + print_error(&format!("Error getting status: {}", error)); + process::exit(1); + } } } None => { @@ -69,7 +72,10 @@ fn main() { print_warning(&warning); } } - Err(error) => print_error(&format!("Error getting status: {}", error)), + Err(error) => { + print_error(&format!("Error getting status: {}", error)); + process::exit(1); + } } } }, @@ -239,7 +245,10 @@ fn main() { print_error(&format!("Error: {}", error)); } } - Err(error) => print_error(&format!("Error getting status: {}", error)), + Err(error) => { + print_error(&format!("Error getting status: {}", error)); + process::exit(1); + } } } cmd::WorktreeAction::Convert(_args) => { From f9d9dc587a2259c166556f855d4d91e05568431b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hannes=20K=C3=B6rber?= Date: Tue, 4 Jan 2022 11:56:55 +0100 Subject: [PATCH 57/68] Add e2e test for repos status --- e2e_tests/helpers.py | 31 +++++++++++++++++++++++++++++++ e2e_tests/test_repos_status.py | 13 +++++++++++++ 2 files changed, 44 insertions(+) create mode 100644 e2e_tests/test_repos_status.py diff --git a/e2e_tests/helpers.py b/e2e_tests/helpers.py index 0cb3882..a5b837f 100644 --- a/e2e_tests/helpers.py +++ b/e2e_tests/helpers.py @@ -186,6 +186,37 @@ class TempGitRepositoryWorktree: del self.remote_2_dir +class RepoTree: + def __init__(self): + pass + + def __enter__(self): + self.root = tempfile.TemporaryDirectory() + self.config = tempfile.NamedTemporaryFile() + with open(self.config.name, "w") as f: + f.write( + f""" + [[trees]] + root = "{self.root.name}" + + [[trees.repos]] + name = "test" + + [[trees.repos]] + name = "test_worktree" + worktree_setup = true + """ + ) + + cmd = grm(["repos", "sync", "--config", self.config.name]) + assert cmd.returncode == 0 + return (self.root.name, self.config.name, ["test", "test_worktree"]) + + def __exit__(self, exc_type, exc_val, exc_tb): + del self.root + del self.config + + class EmptyDir: def __init__(self): pass diff --git a/e2e_tests/test_repos_status.py b/e2e_tests/test_repos_status.py new file mode 100644 index 0000000..8787dea --- /dev/null +++ b/e2e_tests/test_repos_status.py @@ -0,0 +1,13 @@ +#!/usr/bin/env python3 + +import tempfile + +from helpers import * + + +def test_repos_sync_worktree_clone(): + with RepoTree() as (root, config, repos): + cmd = grm(["repos", "status", "--config", config]) + assert cmd.returncode == 0 + for repo in repos: + assert repo in cmd.stdout From 31b90af066926e4e5847aa2a71362a4a3e5f00f7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hannes=20K=C3=B6rber?= Date: Tue, 4 Jan 2022 12:22:02 +0100 Subject: [PATCH 58/68] Properly report status on worktree repos --- src/repo.rs | 6 +----- src/table.rs | 31 +++++++++++++++++-------------- 2 files changed, 18 insertions(+), 19 deletions(-) diff --git a/src/repo.rs b/src/repo.rs index 91a5b01..d277f1a 100644 --- a/src/repo.rs +++ b/src/repo.rs @@ -790,11 +790,7 @@ impl Repo { }; let changes = match is_worktree { - true => { - return Err(String::from( - "Cannot get changes as this is a bare worktree repository", - )) - } + true => None, false => { let statuses = self .0 diff --git a/src/table.rs b/src/table.rs index 6fa7ed2..3ea818d 100644 --- a/src/table.rs +++ b/src/table.rs @@ -30,21 +30,24 @@ fn add_repo_status( true => "\u{2714}", false => "", }, - &match repo_status.changes { - Some(changes) => { - let mut out = Vec::new(); - if changes.files_new > 0 { - out.push(format!("New: {}\n", changes.files_new)) + &match is_worktree { + true => String::from(""), + false => match repo_status.changes { + Some(changes) => { + let mut out = Vec::new(); + if changes.files_new > 0 { + out.push(format!("New: {}\n", changes.files_new)) + } + if changes.files_modified > 0 { + out.push(format!("Modified: {}\n", changes.files_modified)) + } + if changes.files_deleted > 0 { + out.push(format!("Deleted: {}\n", changes.files_deleted)) + } + out.into_iter().collect::().trim().to_string() } - if changes.files_modified > 0 { - out.push(format!("Modified: {}\n", changes.files_modified)) - } - if changes.files_deleted > 0 { - out.push(format!("Deleted: {}\n", changes.files_deleted)) - } - out.into_iter().collect::().trim().to_string() - } - None => String::from("\u{2714}"), + None => String::from("\u{2714}"), + }, }, repo_status .branches From eaf8e2bfa2266c2046679e564c01e3ff324a18f9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hannes=20K=C3=B6rber?= Date: Wed, 5 Jan 2022 15:57:19 +0100 Subject: [PATCH 59/68] rebase: Just continue on empty patch --- src/repo.rs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/repo.rs b/src/repo.rs index d277f1a..5d4dd91 100644 --- a/src/repo.rs +++ b/src/repo.rs @@ -227,6 +227,9 @@ impl Worktree { .map_err(convert_libgit2_error)?; if let Err(error) = rebase.commit(None, &committer, None) { + if error.code() == git2::ErrorCode::Applied { + continue + } rebase.abort().map_err(convert_libgit2_error)?; return Err(convert_libgit2_error(error)); } @@ -323,6 +326,9 @@ impl Worktree { .map_err(convert_libgit2_error)?; if let Err(error) = rebase.commit(None, &committer, None) { + if error.code() == git2::ErrorCode::Applied { + continue + } rebase.abort().map_err(convert_libgit2_error)?; return Err(convert_libgit2_error(error)); } From 9acf5b10d56fb96a35ba5976873312142153b913 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hannes=20K=C3=B6rber?= Date: Sat, 8 Jan 2022 14:13:03 +0100 Subject: [PATCH 60/68] Make cargo fmt happy --- src/repo.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/repo.rs b/src/repo.rs index 5d4dd91..1dc5f35 100644 --- a/src/repo.rs +++ b/src/repo.rs @@ -228,7 +228,7 @@ impl Worktree { if let Err(error) = rebase.commit(None, &committer, None) { if error.code() == git2::ErrorCode::Applied { - continue + continue; } rebase.abort().map_err(convert_libgit2_error)?; return Err(convert_libgit2_error(error)); @@ -327,7 +327,7 @@ impl Worktree { if let Err(error) = rebase.commit(None, &committer, None) { if error.code() == git2::ErrorCode::Applied { - continue + continue; } rebase.abort().map_err(convert_libgit2_error)?; return Err(convert_libgit2_error(error)); From ec45678ce3177a0e0b3eaeaef490862182e75318 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hannes=20K=C3=B6rber?= Date: Sat, 8 Jan 2022 13:55:09 +0100 Subject: [PATCH 61/68] Fix SSH auth, fall back to file if agent fails --- src/repo.rs | 25 ++++++++++--------------- 1 file changed, 10 insertions(+), 15 deletions(-) diff --git a/src/repo.rs b/src/repo.rs index 1dc5f35..c4811de 100644 --- a/src/repo.rs +++ b/src/repo.rs @@ -1,7 +1,7 @@ use serde::{Deserialize, Serialize}; use std::path::Path; -use git2::{Cred, RemoteCallbacks, Repository}; +use git2::Repository; use crate::output::*; @@ -1302,20 +1302,15 @@ fn get_remote_callbacks() -> git2::RemoteCallbacks<'static> { } Ok(()) }); - callbacks.credentials(|_url, username_from_url, _allowed_types| { - let username = match username_from_url { - Some(username) => username, - None => panic!("Could not get username. This is a bug"), - }; - git2::Cred::ssh_key_from_agent(username) - }); callbacks.credentials(|_url, username_from_url, _allowed_types| { let username = match username_from_url { Some(username) => username, None => panic!("Could not get username. This is a bug"), }; - git2::Cred::ssh_key(username, None, &crate::env_home().join(".ssh/id_rsa"), None) + git2::Cred::ssh_key_from_agent(username).or_else(|_| { + git2::Cred::ssh_key(username, None, &crate::env_home().join(".ssh/id_rsa"), None) + }) }); callbacks @@ -1392,17 +1387,17 @@ pub fn clone_repo( match remote.remote_type { RemoteType::Https | RemoteType::File => { let mut builder = git2::build::RepoBuilder::new(); + + let fetchopts = git2::FetchOptions::new(); + builder.bare(is_worktree); + builder.fetch_options(fetchopts); + builder.clone(&remote.url, &clone_target)?; } RemoteType::Ssh => { - let mut callbacks = RemoteCallbacks::new(); - callbacks.credentials(|_url, username_from_url, _allowed_types| { - Cred::ssh_key_from_agent(username_from_url.unwrap()) - }); - let mut fo = git2::FetchOptions::new(); - fo.remote_callbacks(callbacks); + fo.remote_callbacks(get_remote_callbacks()); let mut builder = git2::build::RepoBuilder::new(); builder.bare(is_worktree); From ff48b2a0178fb2e23e8e145f4578ba0f7e16a889 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hannes=20K=C3=B6rber?= Date: Sat, 8 Jan 2022 13:55:32 +0100 Subject: [PATCH 62/68] Properly set up remote & branches after cloning --- src/repo.rs | 29 ++++++++++++++++++++++++++++- 1 file changed, 28 insertions(+), 1 deletion(-) diff --git a/src/repo.rs b/src/repo.rs index c4811de..4e35698 100644 --- a/src/repo.rs +++ b/src/repo.rs @@ -456,6 +456,21 @@ impl Repo { } } + pub fn rename_remote(&self, remote: &RemoteHandle, new_name: &str) -> Result<(), String> { + let failed_refspecs = self + .0 + .remote_rename(&remote.name(), new_name) + .map_err(convert_libgit2_error)?; + + if !failed_refspecs.is_empty() { + return Err(String::from( + "Some non-default refspecs could not be renamed", + )); + } + + Ok(()) + } + pub fn graph_ahead_behind( &self, local_branch: &Branch, @@ -1407,10 +1422,22 @@ pub fn clone_repo( } } + let repo = Repo::open(&clone_target, false)?; + if is_worktree { - let repo = Repo::open(&clone_target, false)?; repo.set_config_push(GitPushDefaultSetting::Upstream)?; } + if remote.name != "origin" { + // unwrap() is safe here as the origin remote will always exist after a successful clone. + // Note that actual errors are handled in the Results Err variant, not in + // the Ok variant option + let origin = repo.find_remote("origin")?.unwrap(); + repo.rename_remote(&origin, &remote.name)?; + } + + let mut active_branch = repo.head_branch()?; + active_branch.set_upstream(&remote.name, &active_branch.name()?)?; + Ok(()) } From e6e9940757fc1cf7389d8118fa151ccff3414233 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hannes=20K=C3=B6rber?= Date: Sat, 8 Jan 2022 14:18:17 +0100 Subject: [PATCH 63/68] Cargo.lock: Updating indexmap v1.7.0 -> v1.8.0 --- Cargo.lock | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 8a9565e..08221a4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -253,9 +253,9 @@ dependencies = [ [[package]] name = "indexmap" -version = "1.7.0" +version = "1.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bc633605454125dec4b66843673f01c7df2b89479b32e0ed634e43a91cff62a5" +checksum = "282a6247722caba404c065016bbfa522806e51714c34f5dfc3e4a3a46fcb4223" dependencies = [ "autocfg", "hashbrown", @@ -684,9 +684,9 @@ dependencies = [ [[package]] name = "syn" -version = "1.0.84" +version = "1.0.85" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ecb2e6da8ee5eb9a61068762a32fa9619cc591ceb055b3687f4cd4051ec2e06b" +checksum = "a684ac3dcd8913827e18cd09a68384ee66c1de24157e3c556c9ab16d85695fb7" dependencies = [ "proc-macro2", "quote", From ed1edf50754049c064366dcdbfb39d931db197a2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hannes=20K=C3=B6rber?= Date: Sat, 8 Jan 2022 14:20:54 +0100 Subject: [PATCH 64/68] e2e_tests/pip: Update GitPython to 3.1.25 --- e2e_tests/requirements.txt | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/e2e_tests/requirements.txt b/e2e_tests/requirements.txt index ee441bc..019f7c2 100644 --- a/e2e_tests/requirements.txt +++ b/e2e_tests/requirements.txt @@ -1,9 +1,8 @@ attrs==21.4.0 gitdb==4.0.9 -GitPython==3.1.24 +GitPython==3.1.25 iniconfig==1.1.1 packaging==21.3 -pkg_resources==0.0.0 pluggy==1.0.0 py==1.11.0 pyparsing==3.0.6 From ab1892cbeb70c1f1a7db2e32db78cfc729b8379d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hannes=20K=C3=B6rber?= Date: Sat, 8 Jan 2022 14:21:19 +0100 Subject: [PATCH 65/68] pip-update: Never write pkg_resources to requirements.txt --- e2e_tests/update_requirementstxt.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/e2e_tests/update_requirementstxt.sh b/e2e_tests/update_requirementstxt.sh index 9b596ba..294da97 100755 --- a/e2e_tests/update_requirementstxt.sh +++ b/e2e_tests/update_requirementstxt.sh @@ -12,7 +12,7 @@ pip3 list --outdated --format=freeze | grep -v '^\-e' | cut -d = -f 1 | while re pip install --upgrade "${package}" version="$(pip show "${package}" | grep '^Version' | cut -d ' ' -f 2)" message="e2e_tests/pip: Update ${package} to ${version}" - pip freeze > requirements.txt + pip freeze | grep -v '^pkg_resources' > requirements.txt git add ./requirements.txt git commit --message "${message}" done From 07fa3ca291b278dc9e8af14976a0d8373c7483fa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hannes=20K=C3=B6rber?= Date: Sat, 8 Jan 2022 14:31:07 +0100 Subject: [PATCH 66/68] depcheck: Update Cargo.lock in same commit --- depcheck/update-cargo-dependencies.py | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/depcheck/update-cargo-dependencies.py b/depcheck/update-cargo-dependencies.py index f836b14..2e50f80 100755 --- a/depcheck/update-cargo-dependencies.py +++ b/depcheck/update-cargo-dependencies.py @@ -84,9 +84,21 @@ for tier in ["dependencies", "dev-dependencies"]: with open("../Cargo.toml", "w") as cargo_config: cargo_config.write(tomlkit.dumps(cargo)) + try: + cmd = subprocess.run( + ["cargo", "update", "-Z", "no-index-update", "--aggressive", "--package", name], + check=True, + capture_output=True, + text=True, + ) + except subprocess.CalledProcessError as e: + print(e.stdout) + print(e.stderr) + raise + message = f"dependencies: Update {name} to {latest_version}" subprocess.run( - ["git", "commit", "--message", message, "../Cargo.toml"], + ["git", "commit", "--message", message, "../Cargo.toml", "../Cargo.lock"], check=True, capture_output=True ) From 1ae0ceff605ba2e683d2d5407dd5b1a2c444a036 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hannes=20K=C3=B6rber?= Date: Sat, 8 Jan 2022 14:34:28 +0100 Subject: [PATCH 67/68] dependencies: Update clap to 3.0.5 --- Cargo.lock | 35 +++++++++++---------------- Cargo.toml | 3 ++- depcheck/update-cargo-dependencies.py | 4 +-- src/grm/cmd.rs | 27 +++++++++------------ 4 files changed, 28 insertions(+), 41 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 08221a4..48712e3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -51,9 +51,9 @@ checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" [[package]] name = "clap" -version = "3.0.0-beta.5" +version = "3.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "feff3878564edb93745d58cf63e17b63f24142506e7a20c87a5521ed7bfb1d63" +checksum = "f6f34b09b9ee8c7c7b400fe2f8df39cafc9538b03d6ba7f4ae13e4cb90bfbb7d" dependencies = [ "atty", "bitflags", @@ -64,16 +64,15 @@ dependencies = [ "strsim", "termcolor", "textwrap", - "unicase", ] [[package]] name = "clap_derive" -version = "3.0.0-beta.5" +version = "3.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b15c6b4f786ffb6192ffe65a36855bc1fc2444bcd0945ae16748dcd6ed7d0d3" +checksum = "41a0645a430ec9136d2d701e54a95d557de12649a9dd7109ced3187e648ac824" dependencies = [ - "heck", + "heck 0.4.0", "proc-macro-error", "proc-macro2", "quote", @@ -231,6 +230,12 @@ dependencies = [ "unicode-segmentation", ] +[[package]] +name = "heck" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2540771e65fc8cb83cd6e8a237f70c319bd5c29f78ed1084ba5d50eeac86f7f9" + [[package]] name = "hermit-abi" version = "0.1.19" @@ -419,9 +424,9 @@ dependencies = [ [[package]] name = "os_str_bytes" -version = "4.2.0" +version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "addaa943333a514159c80c97ff4a93306530d965d27e139188283cd13e06a799" +checksum = "8e22443d1643a904602595ba1cd8f7d896afe56d26712531c5ff73a15b2fbf64" dependencies = [ "memchr", ] @@ -676,7 +681,7 @@ version = "0.22.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "339f799d8b549e3744c7ac7feb216383e4005d94bdb22561b3ab8f3b808ae9fb" dependencies = [ - "heck", + "heck 0.3.3", "proc-macro2", "quote", "syn", @@ -727,9 +732,6 @@ name = "textwrap" version = "0.14.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0066c8d12af8b5acd21e00547c3797fde4e8677254a7ee429176ccebbe93dd80" -dependencies = [ - "unicode-width", -] [[package]] name = "tinyvec" @@ -755,15 +757,6 @@ dependencies = [ "serde", ] -[[package]] -name = "unicase" -version = "2.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "50f37be617794602aabbeee0be4f259dc1778fabe05e2d67ee8f79326d5cb4f6" -dependencies = [ - "version_check", -] - [[package]] name = "unicode-bidi" version = "0.3.7" diff --git a/Cargo.toml b/Cargo.toml index 2f39628..d14b4c5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -50,7 +50,8 @@ version = "=0.13.25" version = "=2.1.0" [dependencies.clap] -version = "=3.0.0-beta.5" +version = "=3.0.5" +features = ["derive", "cargo"] [dependencies.console] version = "=0.15.0" diff --git a/depcheck/update-cargo-dependencies.py b/depcheck/update-cargo-dependencies.py index 2e50f80..8f8f533 100755 --- a/depcheck/update-cargo-dependencies.py +++ b/depcheck/update-cargo-dependencies.py @@ -10,9 +10,7 @@ import tomlkit INDEX_DIR = "crates.io-index" -AUTOUPDATE_DISABLED = [ - "clap", -] +AUTOUPDATE_DISABLED = [] if os.path.exists(INDEX_DIR): subprocess.run( diff --git a/src/grm/cmd.rs b/src/grm/cmd.rs index 017357b..7588499 100644 --- a/src/grm/cmd.rs +++ b/src/grm/cmd.rs @@ -7,10 +7,8 @@ use clap::{AppSettings, Parser}; author = clap::crate_authors!("\n"), about = clap::crate_description!(), long_version = clap::crate_version!(), - license = clap::crate_license!(), setting = AppSettings::DeriveDisplayOrder, setting = AppSettings::PropagateVersion, - setting = AppSettings::HelpRequired, )] pub struct Opts { #[clap(subcommand)] @@ -51,7 +49,7 @@ pub struct Sync { short, long, default_value = "./config.toml", - about = "Path to the configuration file" + help = "Path to the configuration file" )] pub config: String, } @@ -59,13 +57,13 @@ pub struct Sync { #[derive(Parser)] #[clap()] pub struct OptionalConfig { - #[clap(short, long, about = "Path to the configuration file")] + #[clap(short, long, help = "Path to the configuration file")] pub config: Option, } #[derive(Parser)] pub struct Find { - #[clap(about = "The path to search through")] + #[clap(help = "The path to search through")] pub path: String, } @@ -97,23 +95,23 @@ pub enum WorktreeAction { #[derive(Parser)] pub struct WorktreeAddArgs { - #[clap(about = "Name of the worktree")] + #[clap(help = "Name of the worktree")] pub name: String, - #[clap(short = 't', long = "track", about = "Remote branch to track")] + #[clap(short = 't', long = "track", help = "Remote branch to track")] pub track: Option, - #[clap(long = "--no-track", about = "Disable tracking")] + #[clap(long = "--no-track", help = "Disable tracking")] pub no_track: bool, } #[derive(Parser)] pub struct WorktreeDeleteArgs { - #[clap(about = "Name of the worktree")] + #[clap(help = "Name of the worktree")] pub name: String, #[clap( long = "force", - about = "Force deletion, even when there are uncommitted/unpushed changes" + help = "Force deletion, even when there are uncommitted/unpushed changes" )] pub force: bool, } @@ -132,18 +130,15 @@ pub struct WorktreeFetchArgs {} #[derive(Parser)] pub struct WorktreePullArgs { - #[clap( - long = "--rebase", - about = "Perform a rebase instead of a fast-forward" - )] + #[clap(long = "--rebase", help = "Perform a rebase instead of a fast-forward")] pub rebase: bool, } #[derive(Parser)] pub struct WorktreeRebaseArgs { - #[clap(long = "--pull", about = "Perform a pull before rebasing")] + #[clap(long = "--pull", help = "Perform a pull before rebasing")] pub pull: bool, - #[clap(long = "--rebase", about = "Perform a rebase when doing a pull")] + #[clap(long = "--rebase", help = "Perform a rebase when doing a pull")] pub rebase: bool, } From dbf93c8f0e96ff6e03bc75975b186232f966e521 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hannes=20K=C3=B6rber?= Date: Sat, 8 Jan 2022 14:49:20 +0100 Subject: [PATCH 68/68] Release v0.5 --- Cargo.lock | 2 +- Cargo.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 48712e3..0996f5e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -187,7 +187,7 @@ dependencies = [ [[package]] name = "git-repo-manager" -version = "0.4.0" +version = "0.5.0" dependencies = [ "clap", "comfy-table", diff --git a/Cargo.toml b/Cargo.toml index d14b4c5..7a549aa 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "git-repo-manager" -version = "0.4.0" +version = "0.5.0" edition = "2021" authors = [ "Hannes Körber ",