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/CONTRIBUTORS b/CONTRIBUTORS new file mode 100644 index 0000000..c345969 --- /dev/null +++ b/CONTRIBUTORS @@ -0,0 +1 @@ +nonnominandus diff --git a/Cargo.lock b/Cargo.lock index 4b379a1..0996f5e 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", @@ -188,7 +187,7 @@ dependencies = [ [[package]] name = "git-repo-manager" -version = "0.4.0" +version = "0.5.0" dependencies = [ "clap", "comfy-table", @@ -203,9 +202,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", @@ -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" @@ -253,9 +258,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", @@ -287,15 +292,15 @@ 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" -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", @@ -394,9 +399,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" @@ -406,9 +411,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", @@ -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", ] @@ -459,9 +464,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" @@ -489,18 +494,18 @@ dependencies = [ [[package]] name = "proc-macro2" -version = "1.0.32" +version = "1.0.36" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba508cc11742c0dc5c1659771673afbab7a0efab23aa17e854cbab0837ed0b43" +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", ] @@ -595,18 +600,18 @@ checksum = "d29ab0c6d3fc0ee92fe66e2d99f700eab17a8d57d1c1d3b748380fb20baa78cd" [[package]] name = "serde" -version = "1.0.130" +version = "1.0.133" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f12d06de37cf59146fbdecab66aa99f9fe4f78722e3607577a5375d66bd0c913" +checksum = "97565067517b60e2d1ea8b268e59ce036de907ac523ad83a0475da04e818989a" dependencies = [ "serde_derive", ] [[package]] name = "serde_derive" -version = "1.0.130" +version = "1.0.133" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7bc1a1ab1961464eae040d96713baa5a724a8152c1222492465b54322ec508b" +checksum = "ed201699328568d8d08208fdd080e3ff594e6c422e438b6705905da01005d537" dependencies = [ "proc-macro2", "quote", @@ -624,9 +629,9 @@ dependencies = [ [[package]] name = "signal-hook" -version = "0.3.10" +version = "0.3.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c98891d737e271a2954825ef19e46bd16bdb98e2746f2eec4f7a4ef7946efd1" +checksum = "647c97df271007dcea485bb74ffdb57f2e683f1306c854f468a0c244badabf2d" dependencies = [ "libc", "signal-hook-registry", @@ -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", @@ -684,9 +689,9 @@ dependencies = [ [[package]] name = "syn" -version = "1.0.81" +version = "1.0.85" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2afee18b8beb5a596ecb4a2dce128c719b4ba399d34126b9e4396e3f9860966" +checksum = "a684ac3dcd8913827e18cd09a68384ee66c1de24157e3c556c9ab16d85695fb7" dependencies = [ "proc-macro2", "quote", @@ -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" @@ -817,9 +810,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" diff --git a/Cargo.toml b/Cargo.toml index fe81561..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 ", @@ -32,34 +32,35 @@ path = "src/lib.rs" [[bin]] name = "grm" -path = "src/main.rs" +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.133" features = ["derive"] [dependencies.git2] -version = "0.13.24" +version = "=0.13.25" [dependencies.shellexpand] -version = "2.1.0" +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" +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/Justfile b/Justfile index 28acf3e..3f97304 100644 --- a/Justfile +++ b/Justfile @@ -1,8 +1,11 @@ -check: test +check: check-cargo-lock check-pip-requirements test cargo check cargo fmt --check cargo clippy --no-deps -- -Dwarnings +check-cargo-lock: + cargo update --locked + lint-fix: cargo clippy --no-deps --fix @@ -27,14 +30,25 @@ 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 \ - && python -m pytest . + && 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 \ && 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/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 +. diff --git a/depcheck/update-cargo-dependencies.py b/depcheck/update-cargo-dependencies.py index c146432..8f8f533 100755 --- a/depcheck/update-cargo-dependencies.py +++ b/depcheck/update-cargo-dependencies.py @@ -10,6 +10,8 @@ import tomlkit INDEX_DIR = "crates.io-index" +AUTOUPDATE_DISABLED = [] + if os.path.exists(INDEX_DIR): subprocess.run( ["git", "pull", "--depth=1", "origin"], @@ -29,9 +31,16 @@ 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"] + version = dependency["version"].lstrip("=") if len(name) >= 4: info_file = f"{INDEX_DIR}/{name[0:2]}/{name[2:4]}/{name}" elif len(name) == 3: @@ -53,6 +62,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( @@ -62,13 +78,60 @@ 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)}" + 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", "../Cargo.lock"], + 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: +# 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") - sys.exit(0) 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 diff --git a/docs/src/worktrees.md b/docs/src/worktrees.md index c808e7b..847cdad 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 @@ -130,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 @@ -195,6 +229,39 @@ $ 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", +] +``` + +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 @@ -212,6 +279,73 @@ 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. + +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/helpers.py b/e2e_tests/helpers.py index 810c158..a5b837f 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: @@ -95,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} """ @@ -134,20 +161,24 @@ 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 git ls-files | xargs rm -rf mv .git .git-main-working-tree 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 @@ -155,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 @@ -197,12 +259,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 diff --git a/e2e_tests/requirements.txt b/e2e_tests/requirements.txt index dc4e0b7..019f7c2 100644 --- a/e2e_tests/requirements.txt +++ b/e2e_tests/requirements.txt @@ -1,6 +1,6 @@ -attrs==21.2.0 +attrs==21.4.0 gitdb==4.0.9 -GitPython==3.1.24 +GitPython==3.1.25 iniconfig==1.1.1 packaging==21.3 pluggy==1.0.0 @@ -9,4 +9,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 diff --git a/e2e_tests/test_repos_find.py b/e2e_tests/test_repos_find.py index 0608e28..2939e95 100644 --- a/e2e_tests/test_repos_find.py +++ b/e2e_tests/test_repos_find.py @@ -158,3 +158,78 @@ 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" 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 diff --git a/e2e_tests/test_worktree_clean.py b/e2e_tests/test_worktree_clean.py index 79cc00f..bdcf22e 100644 --- a/e2e_tests/test_worktree_clean.py +++ b/e2e_tests/test_worktree_clean.py @@ -1,10 +1,12 @@ #!/usr/bin/env python3 +import pytest + 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) @@ -15,9 +17,7 @@ def test_worktree_clean(): def test_worktree_clean_refusal_no_tracking_branch(): - with TempGitRepositoryWorktree() as base_dir: - before = checksum_directory(base_dir) - + with TempGitRepositoryWorktree() as (base_dir, _commit): cmd = grm(["wt", "add", "test"], cwd=base_dir) assert cmd.returncode == 0 @@ -31,9 +31,7 @@ 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) - + with TempGitRepositoryWorktree() as (base_dir, _commit): cmd = grm(["wt", "add", "test", "--track", "origin/test"], cwd=base_dir) assert cmd.returncode == 0 @@ -49,9 +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: - before = checksum_directory(base_dir) - + with TempGitRepositoryWorktree() as (base_dir, _commit): cmd = grm(["wt", "add", "test", "--track", "origin/test"], cwd=base_dir) assert cmd.returncode == 0 @@ -67,9 +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: - before = checksum_directory(base_dir) - + with TempGitRepositoryWorktree() as (base_dir, _commit): cmd = grm(["wt", "add", "test", "--track", "origin/test"], cwd=base_dir) assert cmd.returncode == 0 @@ -87,9 +81,7 @@ 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) - + with TempGitRepositoryWorktree() as (base_dir, _commit): cmd = grm(["wt", "add", "test", "--track", "origin/test"], cwd=base_dir) assert cmd.returncode == 0 @@ -107,9 +99,7 @@ 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) - + with TempGitRepositoryWorktree() as (base_dir, _commit): cmd = grm(["wt", "add", "test", "--track", "origin/test"], cwd=base_dir) assert cmd.returncode == 0 @@ -127,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 @@ -151,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/e2e_tests/test_worktree_config_presistent_branch.py b/e2e_tests/test_worktree_config_presistent_branch.py new file mode 100644 index 0000000..9174b77 --- /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, _commit): + 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, _commit): + 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, _commit): + 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, _commit): + 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/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_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/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/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 6ddf641..82516bf 100644 --- a/e2e_tests/test_worktrees.py +++ b/e2e_tests/test_worktrees.py @@ -3,37 +3,159 @@ from helpers import * import git +import pytest + +import os.path -def test_worktree_add_simple(): - with TempGitRepositoryWorktree() as base_dir: - before = checksum_directory(base_dir) +@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, _commit): + 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 files = os.listdir(base_dir) - assert len(files) == 2 - assert set(files) == {".git-main-working-tree", "test"} + 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_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(): + with TempGitRepositoryWorktree() as (base_dir, _commit): + 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_with_tracking(): - with TempGitRepositoryWorktree() as base_dir: - before = checksum_directory(base_dir) +def test_worktree_add_into_invalid_subdirectory(): + 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) + 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) + + +@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, _commit): + 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) - assert len(files) == 2 - assert set(files) == {".git-main-working-tree", "test"} + 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 @@ -42,8 +164,117 @@ def test_worktree_add_with_tracking(): 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, _commit): + 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 + + +@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, _commit): + 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(): - 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) @@ -60,9 +291,7 @@ def test_worktree_delete(): def test_worktree_delete_refusal_no_tracking_branch(): - with TempGitRepositoryWorktree() as base_dir: - before = checksum_directory(base_dir) - + with TempGitRepositoryWorktree() as (base_dir, _commit): cmd = grm(["wt", "add", "test"], cwd=base_dir) assert cmd.returncode == 0 @@ -78,9 +307,7 @@ 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) - + with TempGitRepositoryWorktree() as (base_dir, _commit): cmd = grm(["wt", "add", "test", "--track", "origin/test"], cwd=base_dir) assert cmd.returncode == 0 @@ -98,9 +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: - before = checksum_directory(base_dir) - + with TempGitRepositoryWorktree() as (base_dir, _commit): cmd = grm(["wt", "add", "test", "--track", "origin/test"], cwd=base_dir) assert cmd.returncode == 0 @@ -118,9 +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: - before = checksum_directory(base_dir) - + with TempGitRepositoryWorktree() as (base_dir, _commit): cmd = grm(["wt", "add", "test", "--track", "origin/test"], cwd=base_dir) assert cmd.returncode == 0 @@ -140,9 +363,7 @@ 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) - + with TempGitRepositoryWorktree() as (base_dir, _commit): cmd = grm(["wt", "add", "test", "--track", "origin/test"], cwd=base_dir) assert cmd.returncode == 0 @@ -162,9 +383,7 @@ 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) - + with TempGitRepositoryWorktree() as (base_dir, _commit): cmd = grm(["wt", "add", "test", "--track", "origin/test"], cwd=base_dir) assert cmd.returncode == 0 @@ -184,9 +403,7 @@ def test_worktree_delete_refusal_tracking_branch_mismatch(): def test_worktree_delete_force_refusal(): - with TempGitRepositoryWorktree() as base_dir: - before = checksum_directory(base_dir) - + with TempGitRepositoryWorktree() as (base_dir, _commit): cmd = grm(["wt", "add", "test"], cwd=base_dir) assert cmd.returncode == 0 @@ -196,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) diff --git a/e2e_tests/update_requirementstxt.sh b/e2e_tests/update_requirementstxt.sh new file mode 100755 index 0000000..294da97 --- /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 | grep -v '^pkg_resources' > requirements.txt + git add ./requirements.txt + git commit --message "${message}" +done 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 67% rename from src/cmd.rs rename to src/grm/cmd.rs index 6cf4a18..7588499 100644 --- a/src/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, } @@ -87,30 +85,33 @@ 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), + #[clap(about = "Rebase worktree onto default branch")] + Rebase(WorktreeRebaseArgs), } #[derive(Parser)] pub struct WorktreeAddArgs { - #[clap(about = "Name of the worktree")] + #[clap(help = "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")] + #[clap(short = 't', long = "track", help = "Remote branch to track")] pub track: Option, + + #[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, } @@ -124,6 +125,23 @@ pub struct WorktreeConvertArgs {} #[derive(Parser)] pub struct WorktreeCleanArgs {} +#[derive(Parser)] +pub struct WorktreeFetchArgs {} + +#[derive(Parser)] +pub struct WorktreePullArgs { + #[clap(long = "--rebase", help = "Perform a rebase instead of a fast-forward")] + pub rebase: bool, +} + +#[derive(Parser)] +pub struct WorktreeRebaseArgs { + #[clap(long = "--pull", help = "Perform a pull before rebasing")] + pub pull: bool, + #[clap(long = "--rebase", help = "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 new file mode 100644 index 0000000..9a2996a --- /dev/null +++ b/src/grm/main.rs @@ -0,0 +1,447 @@ +use std::path::Path; +use std::process; + +mod cmd; + +use grm::config; +use grm::output::*; +use grm::repo; + +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)); + process::exit(1); + } + } + } + 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)); + process::exit(1); + } + } + } + }, + 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, warnings) = match grm::find_in_tree(&path) { + Ok((repos, warnings)) => (repos, warnings), + 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); + } + for warning in warnings { + print_warning(&warning); + } + } + }, + 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.is_empty() + || split.unwrap().1.is_empty() + { + 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, + }; + + 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)); + process::exit(1); + } + } + } + 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, + &worktree_config, + ) { + 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 + )); + } + grm::WorktreeRemoveFailureReason::NotMerged(message) => { + print_warning(&message); + } + } + 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)); + process::exit(1); + } + } + } + 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); + }); + + match repo.convert_to_worktree(&cwd) { + Ok(_) => print_success("Conversion done"), + 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) => { + 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 + )); + } + } + 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())); + } + } + } + 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/lib.rs b/src/lib.rs index dd32fa6..3bc321b 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, Repo, RepoErrorKind, WorktreeRemoveFailureReason}; const GIT_MAIN_WORKTREE_DIRECTORY: &str = ".git-main-working-tree"; const BRANCH_NAMESPACE_SEPARATOR: &str = "/"; @@ -65,11 +62,11 @@ 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() } -fn env_home() -> PathBuf { +pub fn env_home() -> PathBuf { match std::env::var("HOME") { Ok(path) => Path::new(&path).to_path_buf(), Err(e) => { @@ -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,119 +319,112 @@ 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. +#[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; + let mut warnings = Vec::new(); - 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) => { + warnings.push(format!( "Error opening repo {}{}: {}", path.display(), match is_worktree { true => " as worktree", false => "", }, - e + error )); + continue; } 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); - } - }; - } - 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); - } - }; - } - Some(results) - } - Err(e) => { - print_repo_error( + Ok(remote) => remote, + Err(error) => { + warnings.push(format!( + "{}: Error getting remotes: {}", &path_as_string(&path), - &format!("Error getting remotes: {}", e), - ); - process::exit(1); + error + )); + continue; } }; - repos.push(Repo { + + 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 => { + warnings.push(format!( + "{}: Could not detect remote type of \"{}\"", + &path_as_string(&path), + &url + )); + continue; + } + }; + + results.push(Remote { + name, + url, + remote_type, + }); + } + None => { + warnings.push(format!( + "{}: Remote {} not found", + &path_as_string(&path), + remote_name + )); + continue; + } + }; + } + let remotes = results; + + repos.push(RepoConfig { name: match path == root { 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()), }, - remotes, + remotes: Some(remotes), worktree_setup: is_worktree, }); } - }; + } } - Some((repos, repo_in_root)) + Ok(Some((repos, warnings, repo_in_root))) } -fn find_in_tree(path: &Path) -> Option { - let (repos, repo_in_root): (Vec, bool) = match find_repos(path) { - Some((vec, repo_in_root)) => (vec, repo_in_root), +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, mut repo_warnings, repo_in_root)) => { + warnings.append(&mut repo_warnings); + (vec, repo_in_root) + } None => (Vec::new(), false), }; @@ -476,8 +433,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 +443,178 @@ 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 { - 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 => "", + Ok(( + Tree { + root: root.into_os_string().into_string().unwrap(), + repos: Some(repos), }, - &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)) + warnings, + )) +} + +pub fn add_worktree( + directory: &Path, + name: &str, + subdirectory: Option<&Path>, + track: Option<(&str, &str)>, + no_track: bool, +) -> 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), + })?; + + let config = repo::read_worktree_root_config(directory)?; + + 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)); + } + + let mut remote_branch_exists = false; + + 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()?; } - if changes.files_modified > 0 { - out.push(format!("Modified: {}\n", changes.files_modified)) + Err(_) => { + remote_branch_exists = false; + checkout_commit = default_checkout()?; } - 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), + } + 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()?; } } - ) - }) - .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 mut target_branch = match repo.find_local_branch(name) { + Ok(branchref) => branchref, + Err(_) => repo.create_branch(name, &checkout_commit)?, }; - let repo_name = match path.file_name() { - None => { - print_warning("Cannot detect repo name. Are you working in /?"); - String::from("unknown") + 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() + )); } - Some(file_name) => match file_name.to_str() { - None => { - print_warning("Name of current directory is not valid UTF-8"); - String::from("invalid") + remote.push(branch_name, remote_branch_name, repo) + } + + 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))?; + + push( + &mut remote, + &target_branch.name()?, + remote_branch_name, + &repo, + )?; + + target_branch.set_upstream(remote_name, remote_branch_name)?; } - Some(name) => name.to_string(), - }, - }; + } 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(), + }; - add_repo_status(&mut table, &repo_name, &repo_handle.unwrap(), is_worktree); + 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))?; - println!("{}", table); -} + 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, + )?; -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( - 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 - ))); - } - let worktree_repo = match open_repo(worktree_dir, false) { - Ok(r) => r, - Err(e) => { - return Err(WorktreeRemoveFailureReason::Error(format!( - "Error opening repo: {}", - e - ))); - } - }; - - 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 - ))); + target_branch.set_upstream(&remote_name, &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 - ))); + if let Some(subdirectory) = subdirectory { + std::fs::create_dir_all(subdirectory).map_err(|error| error.to_string())?; } - main_repo.find_worktree(name).unwrap().prune(None).unwrap(); - branch.delete().unwrap(); + repo.new_worktree(name, &path, &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..4e35698 100644 --- a/src/repo.rs +++ b/src/repo.rs @@ -1,10 +1,12 @@ use serde::{Deserialize, Serialize}; use std::path::Path; -use git2::{Cred, RemoteCallbacks, Repository}; +use git2::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 { @@ -13,6 +15,22 @@ pub enum RemoteType { File, } +pub enum WorktreeRemoveFailureReason { + Changes(String), + Error(String), + NotMerged(String), +} + +pub enum WorktreeConversionFailureReason { + Changes, + Ignored, + Error(String), +} + +pub enum GitPushDefaultSetting { + Upstream, +} + #[derive(Debug, PartialEq)] pub enum RepoErrorKind { NotFound, @@ -30,6 +48,54 @@ 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> { + 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 { @@ -53,7 +119,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 +157,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, @@ -103,6 +166,190 @@ 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 + } + + 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(); + + // 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) { + if error.code() == git2::ErrorCode::Applied { + continue; + } + rebase.abort().map_err(convert_libgit2_error)?; + return Err(convert_libgit2_error(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) + } + + 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) { + if error.code() == git2::ErrorCode::Applied { + continue; + } + 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 { + 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::*; @@ -181,64 +428,960 @@ 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 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)?; -pub fn get_repo_config(repo: &git2::Repository) -> Result { - repo.config() - .map_err(|error| format!("Failed getting repository configuration: {}", error)) -} + if !failed_refspecs.is_empty() { + return Err(String::from( + "Some non-default refspecs could not be renamed", + )); + } -pub fn repo_make_bare(repo: &git2::Repository, value: bool) -> Result<(), String> { - let mut config = get_repo_config(repo)?; + Ok(()) + } - 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")) + .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 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)?, + 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, 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(()) + } + + 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<(), 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(WorktreeConversionFailureReason::Error(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(WorktreeConversionFailureReason::Error(format!( + "Failed removing {}", + error + ))); + } + } else if let Err(error) = std::fs::remove_dir_all(&path) { + return Err(WorktreeConversionFailureReason::Error(format!( + "Failed removing {}", + error + ))); + } + } + Err(error) => { + return Err(WorktreeConversionFailureReason::Error(format!( + "Error getting directory entry: {}", + 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| WorktreeConversionFailureReason::Error(format!("Error: {}", error)))?; + + worktree_repo + .set_config_push(GitPushDefaultSetting::Upstream) + .map_err(|error| WorktreeConversionFailureReason::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 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, + 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 => None, + 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(Worktree::new) + .collect()) + } + + pub fn remove_worktree( + &self, + name: &str, + worktree_dir: &Path, + force: bool, + worktree_config: &Option, + ) -> 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", + ))); + } + + 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)?; + + 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!( + "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 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.name() != default_branch_name) + .filter(|worktree| match &config { + None => true, + Some(config) => match &config.persistent_branches { + None => true, + Some(branches) => !branches.iter().any(|branch| branch == worktree.name()), + }, + }) + { + let repo_dir = &directory.join(&worktree.name()); + if repo_dir.exists() { + 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.name(), + &changes + )); + continue; + } + WorktreeRemoveFailureReason::NotMerged(message) => { + warnings.push(message); + continue; + } + WorktreeRemoveFailureReason::Error(error) => { + return Err(error); + } + }, + } + } else { + warnings.push(format!( + "Worktree {} does not have a directory", + &worktree.name() + )); + } + } + 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(), + ); + + let config = read_worktree_root_config(directory)?; + + 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; + } + if dirname == WORKTREE_CONFIG_FILE_NAME { + continue; + } + if dirname == default_branch_name { + continue; + } + if !&worktrees.iter().any(|worktree| worktree.name() == 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() + } +} + +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).or_else(|_| { + git2::Cred::ssh_key(username, None, &crate::env_home().join(".ssh/id_rsa"), None) + }) + }); + + callbacks +} + +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 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 push_options = git2::PushOptions::new(); + push_options.remote_callbacks(get_remote_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 +1391,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!( @@ -259,17 +1402,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); @@ -279,164 +1422,22 @@ pub fn clone_repo( } } + let repo = Repo::open(&clone_target, false)?; + if is_worktree { - let repo = open_repo(&clone_target, false)?; - repo_set_config_push(&repo, "upstream")?; + 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(()) } - -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..3ea818d --- /dev/null +++ b/src/table.rs @@ -0,0 +1,323 @@ +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 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() + } + 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(), + &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(), + ]); + + 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.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.name(), + &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.name() + )); + } + } + 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(), + ); + if dirname == crate::GIT_MAIN_WORKTREE_DIRECTORY { + continue; + } + if !&worktrees.iter().any(|worktree| worktree.name() == 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: &crate::repo::Worktree, + 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..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!( - open_repo(tmpdir.path(), true), + Repo::open(tmpdir.path(), true), Err(RepoError { kind: RepoErrorKind::NotFound }) )); assert!(matches!( - open_repo(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 = init_repo(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 = init_repo(tmpdir.path(), true)?; + let repo = Repo::init(tmpdir.path(), true)?; assert!(repo.is_bare()); assert!(repo.is_empty()?); cleanup_tmpdir(tmpdir);