arnaucube
arnaucube/gitviewPublic Self-hosted repository
add gitview codebase
arnaucube git@arnaucube.com
Mon, 19 Oct 2026 15:05:47 +0200
commit

b54b4db2648a1c6f3aab0626325ca7aebea69c67

A .dockerignore

@@ -0,0 +1,7 @@

+.git +.agents +.aws +.codex +repos +bin +node_modules
A .gitignore

@@ -0,0 +1,3 @@

+/repos/* +!/repos/.gitkeep +/bin/
A LICENCE.txt

@@ -0,0 +1,674 @@

+ GNU GENERAL PUBLIC LICENSE + Version 3, 29 June 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/> + 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. + + <one line to give the program's name and a brief idea of what it does.> + Copyright (C) <year> <name of author> + + 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 <https://www.gnu.org/licenses/>. + +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: + + <program> Copyright (C) <year> <name of author> + 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 +<https://www.gnu.org/licenses/>. + + 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 +<https://www.gnu.org/licenses/why-not-lgpl.html>.
A README.md

@@ -0,0 +1,71 @@

+# Gitview + +A self-hosted Git repository browser written in Go, with a GitHub-inspired interface. Browse files, READMEs, history, diffs, branches, and tags; clone repositories or download archives. Language indicators and size-weighted breakdowns are computed locally using [go-enry](https://github.com/go-enry/go-enry). Vendor, generated, binary, documentation, and data files are excluded; detection can differ from GitHub and does not apply `.gitattributes` overrides. HTTP push is disabled. + +## Run + +Requires Go 1.23+ and Git. + +```sh +go build -o bin/gitview ./cmd +./bin/gitview --config ./config.yaml +``` + +Open http://localhost:5555. In [config.yaml](config.yaml), set `repo.scanPath` to your repository directory, `meta.title` to the browser tab title, and `meta.username` to the name shown in the top bar and on repository pages. Repositories must contain at least one commit. + +Set `meta.logo` to an image path relative to `dirs.static` (for example `branding/logo.png` for `static/branding/logo.png`). The same image is used as the top-bar logo and favicon: + +```yaml +meta: + title: git.website.com + username: yourusername + logo: favicon.svg +``` + +To show selected repositories first, list them in the desired order: + +```yaml +repo: + pinned: [project-one, project-two] +``` + +Names may include the `.git` suffix. Other repositories follow by last updated; ignored or unlisted repositories stay hidden. Restart Gitview after configuration changes. + +Or run with Docker: + +```sh +docker compose -f contrib/docker-compose.yml up --build +``` + +## Mirror GitHub repositories + +Mirror all public repositories owned by a GitHub user (including forks and archived repositories): + +```sh +./scripts/mirror-github.sh githubusername +# Custom directory: use the value of repo.scanPath. +./scripts/mirror-github.sh githubusername /opt/gitview/repos +``` + +Requires Bash, Git, curl, Python 3, and `flock`. Defaults to this project's `repos/` directory. Rerun to discover new repositories and update existing mirrors, descriptions, and default branches. An optional `GITHUB_TOKEN` environment variable increases the API rate limit. + +The script uses `git clone --mirror` and fetches updates with pruning. Upstream updates overwrite mirrored refs; deleted upstream refs are removed. Keep development changes in a separate clone. Unrelated local repositories are skipped with an error; repositories removed from GitHub are kept locally. Gitview displays updates without restarting. + +For automatic updates every five minutes, run `crontab -e` as the repository owner and add this line, replacing the paths and username: + +```cron +*/5 * * * * /opt/gitview/scripts/mirror-github.sh githubusername /opt/gitview/repos >> /opt/gitview/mirror.log 2>&1 +``` + +The script locks the destination to prevent overlapping updates. The owner must be able to write the repositories and log file. With Docker, run synchronization on the host; Compose mounts the repositories read-only into Gitview. + +Mirrors copy Git data, not GitHub issues, pull requests, or release attachments. See the [Git mirror documentation](https://git-scm.com/docs/git-clone#Documentation/git-clone.txt---mirror). + +## Checks + +```sh +go test ./... +go vet ./... +python3 -B scripts/tests/test_mirror_github.py +``` +
A cmd/main.go

@@ -0,0 +1,48 @@

+package main + +import ( + "fmt" + "log" + "net/http" + + "github.com/alexflint/go-arg" + "gitview/internal/config" + "gitview/internal/routes" +) + +var ( + version = "dev" + buildDate = "unknown" +) + +type args struct { + Config string `arg:"--config" default:"./config.yaml" help:"path to config file"` +} + +func (args) Version() string { + return fmt.Sprintf("gitview %s (built %s)", version, buildDate) +} + +func main() { + var a args + arg.MustParse(&a) + + c, err := config.Read(a.Config) + if err != nil { + log.Fatal(err) + } + + if err := UnveilPaths([]string{ + c.Dirs.Static, + c.Repo.ScanPath, + c.Dirs.Templates, + }, + "r"); err != nil { + log.Fatalf("unveil: %s", err) + } + + mux := routes.Handlers(c) + addr := fmt.Sprintf("%s:%d", c.Server.Host, c.Server.Port) + log.Println("starting server on", addr) + log.Fatal(http.ListenAndServe(addr, mux)) +}
A cmd/unveil.go

@@ -0,0 +1,28 @@

+//go:build openbsd +// +build openbsd + +package main + +import ( + "golang.org/x/sys/unix" + "log" +) + +func Unveil(path string, perms string) error { + log.Printf("unveil: \"%s\", %s", path, perms) + return unix.Unveil(path, perms) +} + +func UnveilBlock() error { + log.Printf("unveil: block") + return unix.UnveilBlock() +} + +func UnveilPaths(paths []string, perms string) error { + for _, path := range paths { + if err := Unveil(path, perms); err != nil { + return err + } + } + return UnveilBlock() +}
A cmd/unveil_stub.go

@@ -0,0 +1,18 @@

+//go:build !openbsd +// +build !openbsd + +// Stub functions for GOOS that don't support unix.Unveil() + +package main + +func Unveil(path string, perms string) error { + return nil +} + +func UnveilBlock() error { + return nil +} + +func UnveilPaths(paths []string, perms string) error { + return nil +}
A config.yaml

@@ -0,0 +1,21 @@

+repo: + scanPath: ./repos + # Show these repositories first, in this order (names with or without .git). + pinned: [] + # pinned: [project-one, project-two] + readme: [README.md, readme.md, README, readme] + mainBranch: [main, master] +dirs: + templates: ./templates + static: ./static +meta: + # Image path relative to dirs.static, used for the logo and favicon. + logo: arnaucube.png + title: arnaucube's git mirror + username: arnaucube + description: Mirrors of arnaucube's repos. + syntaxHighlight: github +server: + name: localhost:5555 + host: 0.0.0.0 + port: 5555
A contrib/Dockerfile

@@ -0,0 +1,16 @@

+FROM golang:1.27-alpine AS builder +WORKDIR /app +COPY go.mod go.sum ./ +RUN go mod download +COPY . . +RUN CGO_ENABLED=0 go build -o /gitview ./cmd +FROM alpine:3.23 +RUN apk add --no-cache git ca-certificates +WORKDIR /app +COPY --from=builder /gitview /usr/local/bin/gitview +COPY templates ./templates +COPY static ./static +COPY config.yaml ./config.yaml +RUN mkdir repos +EXPOSE 5555 +CMD ["gitview", "--config", "/app/config.yaml"]
A contrib/docker-compose.yml

@@ -0,0 +1,11 @@

+services: + gitview: + build: + context: .. + dockerfile: contrib/Dockerfile + ports: + - "5555:5555" + volumes: + - ../repos:/app/repos:ro + - ../config.yaml:/app/config.yaml:ro + restart: unless-stopped
A contrib/gitview.service

@@ -0,0 +1,19 @@

+[Unit] +Description=Gitview repository browser +After=network-online.target +Wants=network-online.target + +[Service] +User=git +Group=git +WorkingDirectory=/opt/gitview +ExecStart=/opt/gitview/bin/gitview --config /opt/gitview/config.yaml +ProtectSystem=strict +ProtectHome=strict +NoNewPrivileges=true +PrivateTmp=true +PrivateDevices=true +Restart=on-failure + +[Install] +WantedBy=multi-user.target
A go.mod

@@ -0,0 +1,57 @@

+module gitview + +go 1.23.0 + +require ( + github.com/alecthomas/chroma/v2 v2.14.0 + github.com/alexflint/go-arg v1.6.1 + github.com/bluekeyes/go-gitdiff v0.8.0 + github.com/cyphar/filepath-securejoin v0.4.1 + github.com/dustin/go-humanize v1.0.1 + github.com/go-enry/go-enry/v2 v2.9.6 + github.com/go-git/go-git/v5 v5.13.2 + github.com/microcosm-cc/bluemonday v1.0.27 + github.com/russross/blackfriday/v2 v2.1.0 + github.com/spf13/viper v1.21.0 + golang.org/x/sys v0.30.0 +) + +require ( + github.com/Microsoft/go-winio v0.6.2 // indirect + github.com/ProtonMail/go-crypto v1.1.5 // indirect + github.com/acomagu/bufpipe v1.0.4 // indirect + github.com/alexflint/go-scalar v1.2.0 // indirect + github.com/aymerick/douceur v0.2.0 // indirect + github.com/cloudflare/circl v1.6.0 // indirect + github.com/dlclark/regexp2 v1.11.4 // indirect + github.com/emirpasic/gods v1.18.1 // indirect + github.com/fsnotify/fsnotify v1.9.0 // indirect + github.com/go-enry/go-oniguruma v1.2.1 // indirect + github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect + github.com/go-git/go-billy/v5 v5.6.2 // indirect + github.com/go-viper/mapstructure/v2 v2.4.0 // indirect + github.com/gorilla/css v1.0.1 // indirect + github.com/imdario/mergo v0.3.16 // indirect + github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect + github.com/kevinburke/ssh_config v1.2.0 // indirect + github.com/pelletier/go-toml/v2 v2.2.4 // indirect + github.com/pjbgf/sha1cd v0.3.2 // indirect + github.com/sagikazarmark/locafero v0.11.0 // indirect + github.com/sergi/go-diff v1.3.1 // indirect + github.com/skeema/knownhosts v1.3.1 // indirect + github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect + github.com/spf13/afero v1.15.0 // indirect + github.com/spf13/cast v1.10.0 // indirect + github.com/spf13/pflag v1.0.10 // indirect + github.com/subosito/gotenv v1.6.0 // indirect + github.com/xanzy/ssh-agent v0.3.3 // indirect + go.yaml.in/yaml/v3 v3.0.4 // indirect + golang.org/x/crypto v0.33.0 // indirect + golang.org/x/net v0.35.0 // indirect + golang.org/x/text v0.28.0 // indirect + gopkg.in/warnings.v0 v0.1.2 // indirect +) + +replace github.com/sergi/go-diff => github.com/sergi/go-diff v1.1.0 + +replace github.com/go-git/go-git/v5 => github.com/go-git/go-git/v5 v5.6.1
A go.sum

@@ -0,0 +1,225 @@

+github.com/Microsoft/go-winio v0.5.2/go.mod h1:WpS1mjBmmwHBEWmogvA2mj8546UReBk4v8QkMxJ6pZY= +github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= +github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= +github.com/ProtonMail/go-crypto v0.0.0-20230217124315-7d5c6f04bbb8/go.mod h1:I0gYDMZ6Z5GRU7l58bNFSkPTFN6Yl12dsUlAZ8xy98g= +github.com/ProtonMail/go-crypto v1.1.5 h1:eoAQfK2dwL+tFSFpr7TbOaPNUbPiJj4fLYwwGE1FQO4= +github.com/ProtonMail/go-crypto v1.1.5/go.mod h1:rA3QumHc/FZ8pAHreoekgiAbzpNsfQAosU5td4SnOrE= +github.com/acomagu/bufpipe v1.0.4 h1:e3H4WUzM3npvo5uv95QuJM3cQspFNtFBzvJ2oNjKIDQ= +github.com/acomagu/bufpipe v1.0.4/go.mod h1:mxdxdup/WdsKVreO5GpW4+M/1CE2sMG4jeGJ2sYmHc4= +github.com/alecthomas/assert/v2 v2.7.0 h1:QtqSACNS3tF7oasA8CU6A6sXZSBDqnm7RfpLl9bZqbE= +github.com/alecthomas/assert/v2 v2.7.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k= +github.com/alecthomas/chroma/v2 v2.14.0 h1:R3+wzpnUArGcQz7fCETQBzO5n9IMNi13iIs46aU4V9E= +github.com/alecthomas/chroma/v2 v2.14.0/go.mod h1:QolEbTfmUHIMVpBqxeDnNBj2uoeI4EbYP4i6n68SG4I= +github.com/alecthomas/repr v0.4.0 h1:GhI2A8MACjfegCPVq9f1FLvIBS+DrQ2KQBFZP1iFzXc= +github.com/alecthomas/repr v0.4.0/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4= +github.com/alexflint/go-arg v1.6.1 h1:uZogJ6VDBjcuosydKgvYYRhh9sRCusjOvoOLZopBlnA= +github.com/alexflint/go-arg v1.6.1/go.mod h1:nQ0LFYftLJ6njcaee0sU+G0iS2+2XJQfA8I062D0LGc= +github.com/alexflint/go-scalar v1.2.0 h1:WR7JPKkeNpnYIOfHRa7ivM21aWAdHD0gEWHCx+WQBRw= +github.com/alexflint/go-scalar v1.2.0/go.mod h1:LoFvNMqS1CPrMVltza4LvnGKhaSpc3oyLEBUZVhhS2o= +github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8= +github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4= +github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio= +github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs= +github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk= +github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4= +github.com/bluekeyes/go-gitdiff v0.8.0 h1:Nn1wfw3/XeKoc3lWk+2bEXGUHIx36kj80FM1gVcBk+o= +github.com/bluekeyes/go-gitdiff v0.8.0/go.mod h1:WWAk1Mc6EgWarCrPFO+xeYlujPu98VuLW3Tu+B/85AE= +github.com/bwesterb/go-ristretto v1.2.0/go.mod h1:fUIoIZaG73pV5biE2Blr2xEzDoMj7NFEuV9ekS419A0= +github.com/cloudflare/circl v1.1.0/go.mod h1:prBCrKB9DV4poKZY1l9zBXg2QJY7mvgRvtMxxK7fi4I= +github.com/cloudflare/circl v1.6.0 h1:cr5JKic4HI+LkINy2lg3W2jF8sHCVTBncJr5gIIq7qk= +github.com/cloudflare/circl v1.6.0/go.mod h1:uddAzsPgqdMAYatqJ0lsjX1oECcQLIlRpzZh3pJrofs= +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +github.com/cyphar/filepath-securejoin v0.4.1 h1:JyxxyPEaktOD+GAnqIqTf9A8tHyAG22rowi7HkoSU1s= +github.com/cyphar/filepath-securejoin v0.4.1/go.mod h1:Sdj7gXlvMcPZsbhwhQ33GguGLDGQL7h7bg04C/+u9jI= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dlclark/regexp2 v1.11.4 h1:rPYF9/LECdNymJufQKmri9gV604RvvABwgOA8un7yAo= +github.com/dlclark/regexp2 v1.11.4/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc= +github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ= +github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= +github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= +github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= +github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= +github.com/gliderlabs/ssh v0.3.5 h1:OcaySEmAQJgyYcArR+gGGTHCyE7nvhEMTlYY+Dp8CpY= +github.com/gliderlabs/ssh v0.3.5/go.mod h1:8XB4KraRrX39qHhT6yxPsHedjA08I/uBVwj4xC+/+z4= +github.com/go-enry/go-enry/v2 v2.9.6 h1:np63eOtMV56zfYDHnFVgpEVOk8fr2kmylcMnAZUDbSs= +github.com/go-enry/go-enry/v2 v2.9.6/go.mod h1:9yrj4ES1YrbNb1Wb7/PWYr2bpaCXUGRt0uafN0ISyG8= +github.com/go-enry/go-oniguruma v1.2.1 h1:k8aAMuJfMrqm/56SG2lV9Cfti6tC4x8673aHCcBk+eo= +github.com/go-enry/go-oniguruma v1.2.1/go.mod h1:bWDhYP+S6xZQgiRL7wlTScFYBe023B6ilRZbCAD5Hf4= +github.com/go-git/gcfg v1.5.0/go.mod h1:5m20vg6GwYabIxaOonVkTdrILxQMpEShl1xiMF4ua+E= +github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 h1:+zs/tPmkDkHx3U66DAb0lQFJrpS6731Oaa12ikc+DiI= +github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376/go.mod h1:an3vInlBmSxCcxctByoQdvwPiA7DTK7jaaFDBTtu0ic= +github.com/go-git/go-billy/v5 v5.3.1/go.mod h1:pmpqyWchKfYfrkb/UVH4otLvyi/5gJlGI4Hb3ZqZ3W0= +github.com/go-git/go-billy/v5 v5.4.1/go.mod h1:vjbugF6Fz7JIflbVpl1hJsGjSHNltrSw45YK/ukIvQg= +github.com/go-git/go-billy/v5 v5.6.2 h1:6Q86EsPXMa7c3YZ3aLAQsMA0VlWmy43r6FHqa/UNbRM= +github.com/go-git/go-billy/v5 v5.6.2/go.mod h1:rcFC2rAsp/erv7CMz9GczHcuD0D32fWzH+MJAU+jaUU= +github.com/go-git/go-git-fixtures/v4 v4.3.1 h1:y5z6dd3qi8Hl+stezc8p3JxDkoTRqMAlKnXHuzrfjTQ= +github.com/go-git/go-git-fixtures/v4 v4.3.1/go.mod h1:8LHG1a3SRW71ettAD/jW13h8c6AqjVSeL11RAdgaqpo= +github.com/go-git/go-git/v5 v5.6.1 h1:q4ZRqQl4pR/ZJHc1L5CFjGA1a10u76aV1iC+nh+bHsk= +github.com/go-git/go-git/v5 v5.6.1/go.mod h1:mvyoL6Unz0PiTQrGQfSfiLFhBH1c1e84ylC2MDs4ee8= +github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs= +github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= +github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8= +github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0= +github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM= +github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg= +github.com/imdario/mergo v0.3.13/go.mod h1:4lJ1jqUDcsbIECGy0RUJAXNIhg+6ocWgb1ALK2O4oXg= +github.com/imdario/mergo v0.3.16 h1:wwQJbIsHYGMUyLSPrEq1CT16AhnhNJQ51+4fdHUnCl4= +github.com/imdario/mergo v0.3.16/go.mod h1:WBLT9ZmE3lPoWsEzCh9LPo3TiwVN+ZKEjmz+hD27ysY= +github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A= +github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo= +github.com/jessevdk/go-flags v1.5.0/go.mod h1:Fw0T6WPc1dYxT4mKEZRfG5kJhaTDP9pj1c2EWnYs/m4= +github.com/kevinburke/ssh_config v1.2.0 h1:x584FjTGwHzMwvHx18PXxbBVzfnxogHaAReU4gf13a4= +github.com/kevinburke/ssh_config v1.2.0/go.mod h1:CT57kijsi8u/K/BOFA39wgDQJ9CxiF4nAY/ojJ6r6mM= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/matryer/is v1.2.0 h1:92UTHpy8CDwaJ08GqLDzhhuixiBUUD1p3AU6PHddz4A= +github.com/matryer/is v1.2.0/go.mod h1:2fLPjFQM9rhQ15aVEtbuwhJinnOqrmgXPNdZsdwlWXA= +github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk= +github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA= +github.com/mmcloughlin/avo v0.5.0/go.mod h1:ChHFdoV7ql95Wi7vuq2YT1bwCJqiWdZrQ1im3VujLYM= +github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= +github.com/onsi/gomega v1.34.1 h1:EUMJIKUjM8sKjYbtxQI9A4z2o+rruxnzNvpknOXie6k= +github.com/onsi/gomega v1.34.1/go.mod h1:kU1QgUvBDLXBJq618Xvm2LUX6rSAfRaFRTcdOeDLwwY= +github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= +github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= +github.com/pjbgf/sha1cd v0.3.0/go.mod h1:nZ1rrWOcGJ5uZgEEVL1VUM9iRQiZvWdbZjkKyFzPPsI= +github.com/pjbgf/sha1cd v0.3.2 h1:a9wb0bp1oC2TGwStyn0Umc/IGKQnEgF0vVaZ8QF8eo4= +github.com/pjbgf/sha1cd v0.3.2/go.mod h1:zQWigSxVmsHEZow5qaLtPYxpcKMMQpa09ixqBxuCS6A= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8= +github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4= +github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/sagikazarmark/locafero v0.11.0 h1:1iurJgmM9G3PA/I+wWYIOw/5SyBtxapeHDcg+AAIFXc= +github.com/sagikazarmark/locafero v0.11.0/go.mod h1:nVIGvgyzw595SUSUE6tvCp3YYTeHs15MvlmU87WwIik= +github.com/sergi/go-diff v1.1.0 h1:we8PVUC3FE2uYfodKH/nBHMSetSfHDR6scGdBi+erh0= +github.com/sergi/go-diff v1.1.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM= +github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0= +github.com/skeema/knownhosts v1.1.0/go.mod h1:sKFq3RD6/TKZkSWn8boUbDC7Qkgcv+8XXijpFO6roag= +github.com/skeema/knownhosts v1.3.1 h1:X2osQ+RAjK76shCbvhHHHVl3ZlgDm8apHEHFqRjnBY8= +github.com/skeema/knownhosts v1.3.1/go.mod h1:r7KTdC8l4uxWRyK2TpQZ/1o5HaSzh06ePQNxPwTcfiY= +github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 h1:+jumHNA0Wrelhe64i8F6HNlS8pkoyMv5sreGx2Ry5Rw= +github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8/go.mod h1:3n1Cwaq1E1/1lhQhtRK2ts/ZwZEhjcQeJQ1RuC6Q/8U= +github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I= +github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg= +github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY= +github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo= +github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= +github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU= +github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= +github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= +github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= +github.com/xanzy/ssh-agent v0.3.3 h1:+/15pJfg/RsTxqYcX6fHqOXZwwMP+2VyYWJeWM2qQFM= +github.com/xanzy/ssh-agent v0.3.3/go.mod h1:6dzNDKs0J9rVPHPhaGCukekBHKqfl+L3KghI1Bc68Uw= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/arch v0.1.0/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.0.0-20220525230936-793ad666bf5e/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= +golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= +golang.org/x/crypto v0.0.0-20220826181053-bd7e27e6170d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= +golang.org/x/crypto v0.1.0/go.mod h1:RecgLatLF4+eUMCP1PoPZQb+cVrJcOPbHkTkbkB9sbw= +golang.org/x/crypto v0.6.0/go.mod h1:OFC/31mSvZgRz0V1QTNCzfAI1aIRzbiufJtkMIlEp58= +golang.org/x/crypto v0.33.0 h1:IOBPskki6Lysi0lo9qQvbxiQ+FvsCC/YWOecCHAixus= +golang.org/x/crypto v0.33.0/go.mod h1:bVdXmD7IV/4GdElGPozy6U7lWdRXA4qyRVGJV57uQ5M= +golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 h1:2dVuKD2vS7b0QIHQbpyTISPd0LeHDbnYEryqj5Q1ug8= +golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56/go.mod h1:M4RDyNAINzryxdtnbRXRL/OHtkFuWGRjvuhBJpk2IlY= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/mod v0.6.0/go.mod h1:4mET923SAdbXp2ki8ey+zGs1SLqsuM2Y0uvdZR/fUNI= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.0.0-20220826154423-83b083e8dc8b/go.mod h1:YDH+HFinaLZZlnHAfSS6ZXJJ9M9t4Dl22yv3iI2vPwk= +golang.org/x/net v0.1.0/go.mod h1:Cx3nUiGt4eDBEyega/BKRp+/AlGL8hYe7U9odMt2Cco= +golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= +golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= +golang.org/x/net v0.35.0 h1:T5GQRQb2y08kTAByq9L4/bz8cipCdA8FbRTXewonqY8= +golang.org/x/net v0.35.0/go.mod h1:EglIi67kWsHKlRzzVMUD93VMSWGFOMSZgxFjparz1Qk= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200302150141-5c8b2ff67527/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210320140829-1e4c9ba3b0c4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20211007075335-d3039528d8ac/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220728004956-3c1f35247d10/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220825204002-c680a09ffe64/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.3.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc= +golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.0.0-20220722155259-a9ba230a4035/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.1.0/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= +golang.org/x/term v0.29.0 h1:L6pJp37ocefwRRtYPKSWOWzOtWSxVajvz2ldH/xi3iU= +golang.org/x/term v0.29.0/go.mod h1:6bl4lRlvVuDgSf3179VpIxBF0o10JUpXWOnI7nErv7s= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng= +golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/tools v0.2.0/go.mod h1:y4OqIKeOV/fWJetJ8bXPU1sEVniLMIyDAZWeHdV+NTA= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/warnings.v0 v0.1.2 h1:wFXVbFY8DY5/xOe1ECiWdKCzZlxgshcYVNkBHstARME= +gopkg.in/warnings.v0 v0.1.2/go.mod h1:jksf8JmL6Qr/oQM2OXTHunEvvTAsrWBLb6OOjuVWRNI= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.0/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4=
A internal/config/config.go

@@ -0,0 +1,94 @@

+package config + +import ( + "fmt" + "path/filepath" + "strings" + + "github.com/spf13/viper" +) + +type Config struct { + Repo struct { + ScanPath string `mapstructure:"scanPath"` + Pinned []string `mapstructure:"pinned,omitempty"` + Readme []string `mapstructure:"readme"` + MainBranch []string `mapstructure:"mainBranch"` + Ignore []string `mapstructure:"ignore,omitempty"` + Unlisted []string `mapstructure:"unlisted,omitempty"` + } `mapstructure:"repo"` + Dirs struct { + Templates string `mapstructure:"templates"` + Static string `mapstructure:"static"` + } `mapstructure:"dirs"` + Meta struct { + Title string `mapstructure:"title"` + Username string `mapstructure:"username"` + Description string `mapstructure:"description"` + SyntaxHighlight string `mapstructure:"syntaxHighlight"` + Logo string `mapstructure:"logo"` + Favicon string `mapstructure:"favicon"` + + FaviconHref string + FaviconType string + } `mapstructure:"meta"` + Server struct { + Name string `mapstructure:"name,omitempty"` + Host string `mapstructure:"host"` + Port int `mapstructure:"port"` + } `mapstructure:"server"` +} + +func Read(f string) (*Config, error) { + v := viper.New() + v.SetConfigFile(f) + v.SetEnvPrefix("GITVIEW") + v.AutomaticEnv() + + if err := v.ReadInConfig(); err != nil { + return nil, fmt.Errorf("reading config: %w", err) + } + + c := &Config{} + if err := v.Unmarshal(c); err != nil { + return nil, fmt.Errorf("parsing config: %w", err) + } + + var err error + if c.Repo.ScanPath, err = filepath.Abs(c.Repo.ScanPath); err != nil { + return nil, err + } + if c.Dirs.Templates, err = filepath.Abs(c.Dirs.Templates); err != nil { + return nil, err + } + if c.Dirs.Static, err = filepath.Abs(c.Dirs.Static); err != nil { + return nil, err + } + + // Use one static asset for both the brand mark and favicon; retain legacy config. + if c.Meta.Logo != "" { + c.Meta.Favicon = c.Meta.Logo + } + if c.Meta.Favicon == "" { + c.Meta.Favicon = "favicon.svg" + } + c.Meta.FaviconHref = "/static/" + c.Meta.Favicon + switch strings.ToLower(filepath.Ext(c.Meta.Favicon)) { + case ".svg": + c.Meta.FaviconType = "image/svg+xml" + case ".png": + c.Meta.FaviconType = "image/png" + case ".jpg", ".jpeg": + c.Meta.FaviconType = "image/jpeg" + case ".gif": + c.Meta.FaviconType = "image/gif" + case ".webp": + c.Meta.FaviconType = "image/webp" + case ".avif": + c.Meta.FaviconType = "image/avif" + default: + c.Meta.FaviconType = "image/x-icon" + } + + return c, nil +}
A internal/git/diff.go

@@ -0,0 +1,119 @@

+package git + +import ( + "fmt" + "log" + "strings" + + "github.com/bluekeyes/go-gitdiff/gitdiff" + "github.com/go-git/go-git/v5/plumbing/object" +) + +type TextFragment struct { + Header string + Lines []gitdiff.Line +} + +type Diff struct { + Name struct { + Old string + New string + } + TextFragments []TextFragment + IsBinary bool + IsNew bool + IsDelete bool +} + +// NiceDiff is a nicer git diff representation. +type NiceDiff struct { + Commit struct { + Message string + Author object.Signature + This string + Parent string + } + Stat struct { + FilesChanged int + Insertions int + Deletions int + } + Diff []Diff +} + +func (g *GitRepo) Diff() (*NiceDiff, error) { + c, err := g.r.CommitObject(g.h) + if err != nil { + return nil, fmt.Errorf("commit object: %w", err) + } + + patch := &object.Patch{} + commitTree, err := c.Tree() + parent := &object.Commit{} + if err == nil { + parentTree := &object.Tree{} + if c.NumParents() != 0 { + parent, err = c.Parents().Next() + if err == nil { + parentTree, err = parent.Tree() + if err == nil { + patch, err = parentTree.Patch(commitTree) + if err != nil { + return nil, fmt.Errorf("patch: %w", err) + } + } + } + } else { + patch, err = parentTree.Patch(commitTree) + if err != nil { + return nil, fmt.Errorf("patch: %w", err) + } + } + } + + diffs, _, err := gitdiff.Parse(strings.NewReader(patch.String())) + if err != nil { + log.Println(err) + } + + nd := NiceDiff{} + nd.Commit.This = c.Hash.String() + + if parent.Hash.IsZero() { + nd.Commit.Parent = "" + } else { + nd.Commit.Parent = parent.Hash.String() + } + nd.Commit.Author = c.Author + nd.Commit.Message = c.Message + + for _, d := range diffs { + ndiff := Diff{} + ndiff.Name.New = d.NewName + ndiff.Name.Old = d.OldName + ndiff.IsBinary = d.IsBinary + ndiff.IsNew = d.IsNew + ndiff.IsDelete = d.IsDelete + + for _, tf := range d.TextFragments { + ndiff.TextFragments = append(ndiff.TextFragments, TextFragment{ + Header: tf.Header(), + Lines: tf.Lines, + }) + for _, l := range tf.Lines { + switch l.Op { + case gitdiff.OpAdd: + nd.Stat.Insertions += 1 + case gitdiff.OpDelete: + nd.Stat.Deletions += 1 + } + } + } + + nd.Diff = append(nd.Diff, ndiff) + } + + nd.Stat.FilesChanged = len(diffs) + + return &nd, nil +}
A internal/git/git.go

@@ -0,0 +1,428 @@

+package git + +import ( + "archive/tar" + "errors" + "fmt" + "io" + "io/fs" + "path" + "sort" + "time" + + "github.com/go-git/go-git/v5" + "github.com/go-git/go-git/v5/plumbing" + "github.com/go-git/go-git/v5/plumbing/object" +) + +type GitRepo struct { + r *git.Repository + h plumbing.Hash +} + +type TagList struct { + refs []*TagReference + r *git.Repository +} + +// TagReference is used to list both tag and non-annotated tags. +// Non-annotated tags should only contains a reference. +// Annotated tags should contain its reference and its tag information. +type TagReference struct { + ref *plumbing.Reference + tag *object.Tag +} + +// CommitReference aggregate all the references for a given commit +type CommitReference struct { + commit *object.Commit + refs []*plumbing.Reference +} + +// infoWrapper wraps the property of a TreeEntry so it can export fs.FileInfo +// to tar WriteHeader +type infoWrapper struct { + name string + size int64 + mode fs.FileMode + modTime time.Time + isDir bool +} + +func (t *TagList) Len() int { + return len(t.refs) +} + +func (t *TagList) Swap(i, j int) { + t.refs[i], t.refs[j] = t.refs[j], t.refs[i] +} + +// Less sorts tags in reverse chronological order. +func (t *TagList) Less(i, j int) bool { + var dateI time.Time + var dateJ time.Time + + if t.refs[i].tag != nil { + dateI = t.refs[i].tag.Tagger.When + } else { + c, err := t.r.CommitObject(t.refs[i].ref.Hash()) + if err != nil { + dateI = time.Now() + } else { + dateI = c.Committer.When + } + } + + if t.refs[j].tag != nil { + dateJ = t.refs[j].tag.Tagger.When + } else { + c, err := t.r.CommitObject(t.refs[j].ref.Hash()) + if err != nil { + dateJ = time.Now() + } else { + dateJ = c.Committer.When + } + } + + return dateI.After(dateJ) +} + +func Open(path string, ref string) (*GitRepo, error) { + var err error + g := GitRepo{} + g.r, err = git.PlainOpen(path) + if err != nil { + return nil, fmt.Errorf("opening %s: %w", path, err) + } + + if ref == "" { + head, err := g.r.Head() + if err != nil { + return nil, fmt.Errorf("getting head of %s: %w", path, err) + } + g.h = head.Hash() + } else { + hash, err := g.r.ResolveRevision(plumbing.Revision(ref)) + if err != nil { + return nil, fmt.Errorf("resolving rev %s for %s: %w", ref, path, err) + } + g.h = *hash + } + return &g, nil +} + +func (g *GitRepo) Commits() ([]*CommitReference, error) { + ci, err := g.r.Log(&git.LogOptions{From: g.h}) + if err != nil { + return nil, fmt.Errorf("commits from ref: %w", err) + } + + commitRefs := []*CommitReference{} + if err := ci.ForEach(func(c *object.Commit) error { + commitRefs = append(commitRefs, &CommitReference{commit: c}) + return nil + }); err != nil { + return nil, err + } + + // new we fetch for possible tags for each commit + iter, err := g.r.References() + if err != nil { + return nil, err + } + + if err := iter.ForEach(func(ref *plumbing.Reference) error { + for _, c := range commitRefs { + obj, err := g.r.TagObject(ref.Hash()) + switch { + case err == nil: + if obj.Target == c.commit.Hash { + c.AddReference(ref) + } + case errors.Is(err, plumbing.ErrObjectNotFound): + if c.commit.Hash == ref.Hash() { + c.AddReference(ref) + } + default: + return err + } + } + return nil + }); err != nil { + return nil, err + } + + return commitRefs, nil +} + +func (g *GitRepo) LastCommit() (*CommitReference, error) { + c, err := g.r.CommitObject(g.h) + if err != nil { + return nil, fmt.Errorf("last commit: %w", err) + } + + iter, err := g.r.Tags() + if err != nil { + return nil, err + } + + commitRef := &CommitReference{commit: c} + if err := iter.ForEach(func(ref *plumbing.Reference) error { + obj, err := g.r.TagObject(ref.Hash()) + switch { + case err == nil: + if obj.Target == commitRef.commit.Hash { + commitRef.AddReference(ref) + } + case errors.Is(err, plumbing.ErrObjectNotFound): + if commitRef.commit.Hash == ref.Hash() { + commitRef.AddReference(ref) + } + default: + return err + } + + return nil + }); err != nil { + return nil, err + } + + return commitRef, nil +} + +func (g *GitRepo) FileContent(path string) (string, error) { + c, err := g.r.CommitObject(g.h) + if err != nil { + return "", fmt.Errorf("commit object: %w", err) + } + + tree, err := c.Tree() + if err != nil { + return "", fmt.Errorf("file tree: %w", err) + } + + file, err := tree.File(path) + if err != nil { + return "", err + } + + isbin, _ := file.IsBinary() + + if !isbin { + return file.Contents() + } else { + return "Not displaying binary file", nil + } +} + +func (g *GitRepo) Tags() ([]*TagReference, error) { + iter, err := g.r.Tags() + if err != nil { + return nil, fmt.Errorf("tag objects: %w", err) + } + + tags := make([]*TagReference, 0) + + if err := iter.ForEach(func(ref *plumbing.Reference) error { + obj, err := g.r.TagObject(ref.Hash()) + switch { + case err == nil: + tags = append(tags, &TagReference{ + ref: ref, + tag: obj, + }) + case errors.Is(err, plumbing.ErrObjectNotFound): + tags = append(tags, &TagReference{ + ref: ref, + }) + default: + return err + } + return nil + }); err != nil { + return nil, err + } + + tagList := &TagList{r: g.r, refs: tags} + sort.Sort(tagList) + return tags, nil +} + +func (g *GitRepo) Branches() ([]*plumbing.Reference, error) { + bi, err := g.r.Branches() + if err != nil { + return nil, fmt.Errorf("branchs: %w", err) + } + + branches := []*plumbing.Reference{} + + _ = bi.ForEach(func(ref *plumbing.Reference) error { + branches = append(branches, ref) + return nil + }) + + return branches, nil +} + +func (g *GitRepo) FindMainBranch(branches []string) (string, error) { + for _, b := range branches { + _, err := g.r.ResolveRevision(plumbing.Revision(b)) + if err == nil { + return b, nil + } + } + // Repositories may use a default branch other than main or master. + head, err := g.r.Head() + if err == nil && head.Name().IsBranch() { + return head.Name().Short(), nil + } + return "HEAD", nil +} + +// WriteTar writes itself from a tree into a binary tar file format. +// prefix is root folder to be appended. +func (g *GitRepo) WriteTar(w io.Writer, prefix string) error { + tw := tar.NewWriter(w) + defer func() { _ = tw.Close() }() + + c, err := g.r.CommitObject(g.h) + if err != nil { + return fmt.Errorf("commit object: %w", err) + } + + tree, err := c.Tree() + if err != nil { + return err + } + + walker := object.NewTreeWalker(tree, true, nil) + defer walker.Close() + + name, entry, err := walker.Next() + for ; err == nil; name, entry, err = walker.Next() { + info, err := newInfoWrapper(name, prefix, &entry, tree) + if err != nil { + return err + } + + header, err := tar.FileInfoHeader(info, "") + if err != nil { + return err + } + + err = tw.WriteHeader(header) + if err != nil { + return err + } + + if !info.IsDir() { + file, err := tree.File(name) + if err != nil { + return err + } + + reader, err := file.Reader() + if err != nil { + return err + } + + _, err = io.Copy(tw, reader) + if err != nil { + _ = reader.Close() + return err + } + _ = reader.Close() + } + } + + return nil +} + +func newInfoWrapper( + name string, + prefix string, + entry *object.TreeEntry, + tree *object.Tree, +) (*infoWrapper, error) { + var ( + size int64 + mode fs.FileMode + isDir bool + ) + + if entry.Mode.IsFile() { + file, err := tree.TreeEntryFile(entry) + if err != nil { + return nil, err + } + mode = fs.FileMode(file.Mode) + + size, err = tree.Size(name) + if err != nil { + return nil, err + } + } else { + isDir = true + mode = fs.ModeDir | fs.ModePerm + } + + fullname := path.Join(prefix, name) + return &infoWrapper{ + name: fullname, + size: size, + mode: mode, + modTime: time.Unix(0, 0), + isDir: isDir, + }, nil +} + +func (i *infoWrapper) Name() string { + return i.name +} + +func (i *infoWrapper) Size() int64 { + return i.size +} + +func (i *infoWrapper) Mode() fs.FileMode { + return i.mode +} + +func (i *infoWrapper) ModTime() time.Time { + return i.modTime +} + +func (i *infoWrapper) IsDir() bool { + return i.isDir +} + +func (i *infoWrapper) Sys() any { + return nil +} + +func (t *TagReference) Name() string { + return t.ref.Name().Short() +} + +func (t *TagReference) Message() string { + if t.tag != nil { + return t.tag.Message + } + return "" +} + +func (c *CommitReference) Commit() *object.Commit { + return c.commit +} + +func (c *CommitReference) HasReference() bool { + return len(c.refs) > 0 +} + +func (c *CommitReference) References() []*plumbing.Reference { + return c.refs +} + +func (c *CommitReference) AddReference(ref *plumbing.Reference) { + c.refs = append(c.refs, ref) +}
A internal/git/languages.go

@@ -0,0 +1,110 @@

+package git + +import ( + "fmt" + "io" + "slices" + "sort" + "sync" + + enry "github.com/go-enry/go-enry/v2" + "github.com/go-git/go-git/v5/plumbing" + "github.com/go-git/go-git/v5/plumbing/filemode" + "github.com/go-git/go-git/v5/plumbing/object" +) + +// LanguageStat describes a language's share of the repository's source bytes. +type LanguageStat struct { + Name string + Color string + Bytes int64 + Percentage float64 +} + +// Immutable Git tree IDs keep cached statistics valid across mirror updates. +var languageStatsCache = struct { + sync.Mutex + entries map[plumbing.Hash][]LanguageStat + order []plumbing.Hash +}{entries: make(map[plumbing.Hash][]LanguageStat)} + +// Languages classifies committed source files without requiring a working tree. +func (g *GitRepo) Languages() ([]LanguageStat, error) { + commit, err := g.r.CommitObject(g.h) + if err != nil { + return nil, fmt.Errorf("language commit: %w", err) + } + tree, err := commit.Tree() + if err != nil { + return nil, fmt.Errorf("language tree: %w", err) + } + languageStatsCache.Lock() + cached, ok := languageStatsCache.entries[tree.Hash] + languageStatsCache.Unlock() + if ok { + return slices.Clone(cached), nil + } + + sizes := make(map[string]int64) + err = tree.Files().ForEach(func(file *object.File) error { + if file.Size == 0 || file.Mode == filemode.Symlink || enry.IsVendor(file.Name) || enry.IsDocumentation(file.Name) { + return nil + } + reader, err := file.Reader() + if err != nil { + return err + } + // Bound content inspection while weighing every included file by its full size. + content, readErr := io.ReadAll(io.LimitReader(reader, 64*1024)) + closeErr := reader.Close() + if readErr != nil { + return readErr + } + if closeErr != nil { + return closeErr + } + if enry.IsBinary(content) || enry.IsGenerated(file.Name, content) { + return nil + } + name := enry.GetLanguage(file.Name, content) + kind := enry.GetLanguageType(name) + if kind != enry.Programming && kind != enry.Markup { + return nil + } + if group := enry.GetLanguageGroup(name); group != "" { + name = group + } + sizes[name] += file.Size + return nil + }) + if err != nil { + return nil, fmt.Errorf("classifying languages: %w", err) + } + stats := make([]LanguageStat, 0, len(sizes)) + var total int64 + for name, size := range sizes { + stats = append(stats, LanguageStat{Name: name, Color: enry.GetColor(name), Bytes: size}) + total += size + } + sort.Slice(stats, func(i, j int) bool { + if stats[i].Bytes == stats[j].Bytes { + return stats[i].Name < stats[j].Name + } + return stats[i].Bytes > stats[j].Bytes + }) + for i := range stats { + stats[i].Percentage = 100 * float64(stats[i].Bytes) / float64(total) + } + languageStatsCache.Lock() + defer languageStatsCache.Unlock() + if _, exists := languageStatsCache.entries[tree.Hash]; !exists { + const maxCachedTrees = 128 + if len(languageStatsCache.order) == maxCachedTrees { + delete(languageStatsCache.entries, languageStatsCache.order[0]) + languageStatsCache.order = languageStatsCache.order[1:] + } + languageStatsCache.order = append(languageStatsCache.order, tree.Hash) + languageStatsCache.entries[tree.Hash] = slices.Clone(stats) + } + return stats, nil +}
A internal/git/languages_test.go

@@ -0,0 +1,184 @@

+package git + +import ( + "math" + "os" + "path/filepath" + "testing" + "time" + + gogit "github.com/go-git/go-git/v5" + "github.com/go-git/go-git/v5/plumbing/object" +) + +// commitLanguageFiles creates real Git objects, including bare-mirror compatible trees. +func commitLanguageFiles(t *testing.T, files map[string]string) (string, *gogit.Repository) { + t.Helper() + path := t.TempDir() + repo, err := gogit.PlainInit(path, false) + if err != nil { + t.Fatal(err) + } + wt, err := repo.Worktree() + if err != nil { + t.Fatal(err) + } + for name, content := range files { + target := filepath.Join(path, name) + if err := os.MkdirAll(filepath.Dir(target), 0755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(target, []byte(content), 0644); err != nil { + t.Fatal(err) + } + if _, err := wt.Add(name); err != nil { + t.Fatal(err) + } + } + author := &object.Signature{Name: "Test Author", Email: "author@example.com", When: time.Now()} + if _, err := wt.Commit("Language fixtures", &gogit.CommitOptions{Author: author}); err != nil { + t.Fatal(err) + } + return path, repo +} + +func TestLanguagesSourceBytesAndExclusions(t *testing.T) { + files := map[string]string{ + "main.go": "package main\n\nfunc main() {}\n", + "lib/helpers.go": "package main\n\nconst answer = 42\n", + "src/index.js": "export const answer = 42;\n", + "page.html": "<!doctype html><html><body>Hello</body></html>\n", + "README.md": "# Documentation\n", + "data.json": "{\"name\":\"data\"}", + "settings.yaml": "enabled: true\n", + "binary.go": "\x00\x01\x02not source", + "empty.py": "", + "vendor/dep/code.go": "package dependency\n", + "node_modules/pkg/index.js": "export const dependency = true;\n", + "generated.go": "// Code generated by a tool. DO NOT EDIT.\npackage main\n", + } + path, _ := commitLanguageFiles(t, files) + if err := os.Symlink("main.go", filepath.Join(path, "alias.py")); err != nil { + t.Fatal(err) + } + repo, err := gogit.PlainOpen(path) + if err != nil { + t.Fatal(err) + } + wt, err := repo.Worktree() + if err != nil { + t.Fatal(err) + } + if _, err := wt.Add("alias.py"); err != nil { + t.Fatal(err) + } + if _, err := wt.Commit("Add source link", &gogit.CommitOptions{Author: &object.Signature{Name: "Test", Email: "test@example.com", When: time.Now()}}); err != nil { + t.Fatal(err) + } + g, err := Open(path, "") + if err != nil { + t.Fatal(err) + } + stats, err := g.Languages() + if err != nil { + t.Fatal(err) + } + want := map[string]int64{"Go": int64(len(files["main.go"]) + len(files["lib/helpers.go"])), "JavaScript": int64(len(files["src/index.js"])), "HTML": int64(len(files["page.html"]))} + if len(stats) != len(want) { + t.Fatalf("languages: %+v; want %v", stats, want) + } + var total int64 + for _, size := range want { + total += size + } + for i, stat := range stats { + if stat.Bytes != want[stat.Name] { + t.Errorf("%s: %d bytes, want %d", stat.Name, stat.Bytes, want[stat.Name]) + } + percentage := 100 * float64(want[stat.Name]) / float64(total) + if math.Abs(stat.Percentage-percentage) > 1e-8 { + t.Errorf("%s: percentage %f, want %f", stat.Name, stat.Percentage, percentage) + } + if i > 0 && stat.Bytes > stats[i-1].Bytes { + t.Error("languages are not sorted by code size") + } + } + // Mirrors have no working tree but must produce the same statistics. + barePath := filepath.Join(t.TempDir(), "mirror.git") + if _, err := gogit.PlainClone(barePath, true, &gogit.CloneOptions{URL: path}); err != nil { + t.Fatal(err) + } + bare, err := Open(barePath, "") + if err != nil { + t.Fatal(err) + } + bareStats, err := bare.Languages() + if err != nil { + t.Fatal(err) + } + if len(bareStats) != len(stats) { + t.Fatalf("bare mirror statistics: %+v", bareStats) + } +} + +func TestLanguagesCacheFollowsCommits(t *testing.T) { + path, repo := commitLanguageFiles(t, map[string]string{"main.go": "package main\n"}) + g, err := Open(path, "") + if err != nil { + t.Fatal(err) + } + before, err := g.Languages() + if err != nil { + t.Fatal(err) + } + if len(before) != 1 || before[0].Name != "Go" { + t.Fatalf("initial languages: %+v", before) + } + before[0].Name = "mutated" + cached, err := g.Languages() + if err != nil { + t.Fatal(err) + } + if cached[0].Name != "Go" { + t.Fatal("caller modified the cached statistics") + } + if err := os.WriteFile(filepath.Join(path, "main.py"), []byte("print('new language')\n"), 0644); err != nil { + t.Fatal(err) + } + wt, err := repo.Worktree() + if err != nil { + t.Fatal(err) + } + if _, err := wt.Add("main.py"); err != nil { + t.Fatal(err) + } + if _, err := wt.Commit("Add Python", &gogit.CommitOptions{Author: &object.Signature{Name: "Test", Email: "test@example.com", When: time.Now()}}); err != nil { + t.Fatal(err) + } + updated, err := Open(path, "") + if err != nil { + t.Fatal(err) + } + after, err := updated.Languages() + if err != nil { + t.Fatal(err) + } + if len(after) != 2 || after[0].Name != "Python" { + t.Fatalf("new commit did not refresh statistics: %+v", after) + } +} + +func TestLanguagesDocumentationOnly(t *testing.T) { + path, _ := commitLanguageFiles(t, map[string]string{"README.md": "# Just documentation\n"}) + g, err := Open(path, "") + if err != nil { + t.Fatal(err) + } + stats, err := g.Languages() + if err != nil { + t.Fatal(err) + } + if len(stats) != 0 { + t.Fatalf("documentation counted as source: %+v", stats) + } +}
A internal/git/service/service.go

@@ -0,0 +1,121 @@

+package service + +import ( + "bytes" + "fmt" + "io" + "log" + "net/http" + "os/exec" + "strings" + "syscall" +) + +// Mostly from charmbracelet/soft-serve and sosedoff/gitkit. + +type ServiceCommand struct { + Dir string + Stdin io.Reader + Stdout http.ResponseWriter +} + +func (c *ServiceCommand) InfoRefs() error { + cmd := exec.Command("git", []string{ + "upload-pack", + "--stateless-rpc", + "--advertise-refs", + ".", + }...) + + cmd.Dir = c.Dir + cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} + stdoutPipe, _ := cmd.StdoutPipe() + cmd.Stderr = cmd.Stdout + + if err := cmd.Start(); err != nil { + log.Printf("git: failed to start git-upload-pack (info/refs): %s", err) + return err + } + + if err := packLine(c.Stdout, "# service=git-upload-pack\n"); err != nil { + log.Printf("git: failed to write pack line: %s", err) + return err + } + + if err := packFlush(c.Stdout); err != nil { + log.Printf("git: failed to flush pack: %s", err) + return err + } + + buf := bytes.Buffer{} + if _, err := io.Copy(&buf, stdoutPipe); err != nil { + log.Printf("git: failed to copy stdout to tmp buffer: %s", err) + return err + } + + if err := cmd.Wait(); err != nil { + out := strings.Builder{} + _, _ = io.Copy(&out, &buf) + log.Printf("git: failed to run git-upload-pack; err: %s; output: %s", err, out.String()) + return err + } + + if _, err := io.Copy(c.Stdout, &buf); err != nil { + log.Printf("git: failed to copy stdout: %s", err) + } + + return nil +} + +func (c *ServiceCommand) UploadPack() error { + cmd := exec.Command("git", []string{ + "-c", "uploadpack.allowFilter=true", + "upload-pack", + "--stateless-rpc", + ".", + }...) + cmd.Dir = c.Dir + cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} + + stdoutPipe, _ := cmd.StdoutPipe() + cmd.Stderr = cmd.Stdout + defer func() { _ = stdoutPipe.Close() }() + + stdinPipe, err := cmd.StdinPipe() + if err != nil { + return err + } + defer func() { _ = stdinPipe.Close() }() + + if err := cmd.Start(); err != nil { + log.Printf("git: failed to start git-upload-pack: %s", err) + return err + } + + if _, err := io.Copy(stdinPipe, c.Stdin); err != nil { + log.Printf("git: failed to copy stdin: %s", err) + return err + } + _ = stdinPipe.Close() + + if _, err := io.Copy(newWriteFlusher(c.Stdout), stdoutPipe); err != nil { + log.Printf("git: failed to copy stdout: %s", err) + return err + } + if err := cmd.Wait(); err != nil { + log.Printf("git: failed to wait for git-upload-pack: %s", err) + return err + } + + return nil +} + +func packLine(w io.Writer, s string) error { + _, err := fmt.Fprintf(w, "%04x%s", len(s)+4, s) + return err +} + +func packFlush(w io.Writer) error { + _, err := fmt.Fprint(w, "0000") + return err +}
A internal/git/service/write_flusher.go

@@ -0,0 +1,25 @@

+package service + +import ( + "io" + "net/http" +) + +func newWriteFlusher(w http.ResponseWriter) io.Writer { + return writeFlusher{w.(interface { + io.Writer + http.Flusher + })} +} + +type writeFlusher struct { + wf interface { + io.Writer + http.Flusher + } +} + +func (w writeFlusher) Write(p []byte) (int, error) { + defer w.wf.Flush() + return w.wf.Write(p) +}
A internal/git/tree.go

@@ -0,0 +1,66 @@

+package git + +import ( + "fmt" + + "github.com/go-git/go-git/v5/plumbing/object" +) + +func (g *GitRepo) FileTree(path string) ([]NiceTree, error) { + c, err := g.r.CommitObject(g.h) + if err != nil { + return nil, fmt.Errorf("commit object: %w", err) + } + + files := []NiceTree{} + tree, err := c.Tree() + if err != nil { + return nil, fmt.Errorf("file tree: %w", err) + } + + if path == "" { + files = makeNiceTree(tree) + } else { + o, err := tree.FindEntry(path) + if err != nil { + return nil, err + } + + if !o.Mode.IsFile() { + subtree, err := tree.Tree(path) + if err != nil { + return nil, err + } + + files = makeNiceTree(subtree) + } + } + + return files, nil +} + +// NiceTree is a nicer git tree representation. +type NiceTree struct { + Name string + Mode string + Size int64 + IsFile bool + IsSubtree bool +} + +func makeNiceTree(t *object.Tree) []NiceTree { + nts := []NiceTree{} + + for _, e := range t.Entries { + mode, _ := e.Mode.ToOSFileMode() + sz, _ := t.Size(e.Name) + nts = append(nts, NiceTree{ + Name: e.Name, + Mode: mode.String(), + IsFile: e.Mode.IsFile(), + Size: sz, + }) + } + + return nts +}
A internal/routes/branding_test.go

@@ -0,0 +1,93 @@

+package routes + +import ( + "fmt" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "regexp" + "strings" + "testing" + + "gitview/internal/config" +) + +// Verify configured branding renders consistently and resolves to the served asset. +func TestConfiguredBranding(t *testing.T) { + c, _ := testWorkspace(t) + static := t.TempDir() + if err := os.MkdirAll(filepath.Join(static, "branding"), 0755); err != nil { + t.Fatal(err) + } + const svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1 1"><circle r="1"/></svg>` + for _, name := range []string{"favicon.svg", "branding/owner mark.svg"} { + if err := os.WriteFile(filepath.Join(static, name), []byte(svg), 0644); err != nil { + t.Fatal(err) + } + } + favicon := regexp.MustCompile(`<link rel="icon" href="([^"]+)"`) + logo := regexp.MustCompile(`<img class="brand-mark" src="([^"]+)"`) + for _, tt := range []struct{ name, settings, asset string }{ + {"logo overrides legacy favicon", " logo: branding/owner mark.svg\n favicon: nonexistent.svg\n", "/static/branding/owner%20mark.svg"}, + {"legacy favicon", " favicon: branding/owner mark.svg\n", "/static/branding/owner%20mark.svg"}, + {"default logo", "", "/static/favicon.svg"}, + } { + t.Run(tt.name, func(t *testing.T) { + path := filepath.Join(t.TempDir(), "config.yaml") + yaml := fmt.Sprintf("repo:\n scanPath: %q\n mainBranch: [main]\ndirs:\n templates: %q\n static: %q\nmeta:\n username: arnaucube\n title: browser-only-title\n%s", c.Repo.ScanPath, c.Dirs.Templates, static, tt.settings) + if err := os.WriteFile(path, []byte(yaml), 0644); err != nil { + t.Fatal(err) + } + loaded, err := config.Read(path) + if err != nil { + t.Fatal(err) + } + handler := Handlers(loaded) + for _, page := range []struct { + url string + status int + }{{"/", 200}, {"/sample", 200}, {"/missing", 404}} { + w := httptest.NewRecorder() + handler.ServeHTTP(w, httptest.NewRequest("GET", page.url, nil)) + if w.Code != page.status { + t.Fatalf("%s: status %d", page.url, w.Code) + } + body := w.Body.String() + icon := favicon.FindStringSubmatch(body) + brand := logo.FindStringSubmatch(body) + if len(icon) != 2 || len(brand) != 2 { + t.Fatalf("%s: missing favicon or logo", page.url) + } + if icon[1] != tt.asset || brand[1] != tt.asset { + t.Errorf("%s: favicon %q, logo %q; want %q", page.url, icon[1], brand[1], tt.asset) + } + _, header, found := strings.Cut(body, `<header class="topbar">`) + if !found { + t.Fatalf("%s: missing topbar", page.url) + } + topbar, _, _ := strings.Cut(header, "</header>") + if !strings.Contains(topbar, `class="brand-name">arnaucube</span>`) || strings.Contains(topbar, "browser-only-title") || strings.Contains(topbar, "top-context") { + t.Errorf("%s: topbar does not use only the configured username", page.url) + } + if !strings.Contains(body, "browser-only-title</title>") { + t.Errorf("%s: browser title was lost", page.url) + } + } + w := httptest.NewRecorder() + handler.ServeHTTP(w, httptest.NewRequest("GET", tt.asset, nil)) + if w.Code != http.StatusOK || w.Body.String() != svg { + t.Fatalf("logo request: status %d, body %q", w.Code, w.Body.String()) + } + if got := w.Header().Get("Content-Type"); !strings.HasPrefix(got, "image/svg+xml") { + t.Errorf("logo content type: %q", got) + } + loaded.Repo.ScanPath = filepath.Join(t.TempDir(), "missing") + w = httptest.NewRecorder() + handler.ServeHTTP(w, httptest.NewRequest("GET", "/", nil)) + if w.Code != 500 || !strings.Contains(w.Body.String(), tt.asset) { + t.Errorf("server error page lost configured branding: status %d", w.Code) + } + }) + } +}
A internal/routes/git.go

@@ -0,0 +1,88 @@

+package routes + +import ( + "compress/gzip" + "io" + "log" + "net/http" + "path/filepath" + + securejoin "github.com/cyphar/filepath-securejoin" + "gitview/internal/git/service" +) + +func (d *deps) InfoRefs(w http.ResponseWriter, r *http.Request) { + name := r.PathValue("name") + if d.isIgnored(name) { + d.Write404(w) + return + } + name = filepath.Clean(name) + + repo, err := securejoin.SecureJoin(d.c.Repo.ScanPath, name) + if err != nil { + log.Printf("securejoin error: %v", err) + d.Write404(w) + return + } + + w.Header().Set("content-type", "application/x-git-upload-pack-advertisement") + w.WriteHeader(http.StatusOK) + + cmd := service.ServiceCommand{ + Dir: repo, + Stdout: w, + } + + if err := cmd.InfoRefs(); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + log.Printf("git: failed to execute git-upload-pack (info/refs) %s", err) + return + } +} + +func (d *deps) UploadPack(w http.ResponseWriter, r *http.Request) { + name := r.PathValue("name") + if d.isIgnored(name) { + d.Write404(w) + return + } + name = filepath.Clean(name) + + repo, err := securejoin.SecureJoin(d.c.Repo.ScanPath, name) + if err != nil { + log.Printf("securejoin error: %v", err) + d.Write404(w) + return + } + + w.Header().Set("content-type", "application/x-git-upload-pack-result") + w.Header().Set("Connection", "Keep-Alive") + w.Header().Set("Transfer-Encoding", "chunked") + w.WriteHeader(http.StatusOK) + + cmd := service.ServiceCommand{ + Dir: repo, + Stdout: w, + } + + var reader io.ReadCloser + reader = r.Body + + if r.Header.Get("Content-Encoding") == "gzip" { + reader, err = gzip.NewReader(r.Body) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + log.Printf("git: failed to create gzip reader: %s", err) + return + } + defer func() { _ = reader.Close() }() + } + + cmd.Stdin = reader + if err := cmd.UploadPack(); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + log.Printf("git: failed to execute git-upload-pack %s", err) + return + } +}
A internal/routes/handler.go

@@ -0,0 +1,55 @@

+package routes + +import ( + "net/http" + + "gitview/internal/config" +) + +// Checks for gitprotocol-http(5) specific smells; if found, passes +// the request on to the git http service, else render the web frontend. +func (d *deps) Multiplex(w http.ResponseWriter, r *http.Request) { + path := r.PathValue("rest") + + if r.URL.Query().Get("service") == "git-receive-pack" || path == "git-receive-pack" { + w.WriteHeader(http.StatusBadRequest) + _, _ = w.Write([]byte("no pushing allowed!")) + return + } + + switch { + case path == "info/refs" && + r.URL.Query().Get("service") == "git-upload-pack" && + r.Method == http.MethodGet: + d.InfoRefs(w, r) + case path == "git-upload-pack" && r.Method == http.MethodPost: + d.UploadPack(w, r) + case r.Method == http.MethodGet && path == "": + d.RepoIndex(w, r) + default: + d.Write404(w) + } +} + +func Handlers(c *config.Config) *http.ServeMux { + mux := http.NewServeMux() + d := deps{c} + + mux.HandleFunc("GET /{$}", d.Index) + mux.HandleFunc("GET /{name}", d.Multiplex) + mux.HandleFunc("POST /{name}", d.Multiplex) + mux.HandleFunc("GET /{name}/tree/{ref}/{rest...}", d.RepoTree) + mux.HandleFunc("GET /{name}/blob/{ref}/{rest...}", d.FileContent) + mux.HandleFunc("GET /{name}/log/{ref}", d.Log) + mux.HandleFunc("GET /{name}/archive/{file}", d.Archive) + mux.HandleFunc("GET /{name}/commit/{ref}", d.Diff) + mux.HandleFunc("GET /{name}/refs/{$}", d.Refs) + mux.HandleFunc("GET /{name}/{rest...}", d.Multiplex) + mux.HandleFunc("POST /{name}/{rest...}", d.Multiplex) + + // Route static paths separately so nested assets cannot conflict with repository routes. + handler := http.NewServeMux() + handler.HandleFunc("GET /static/{file...}", d.ServeStatic) + handler.Handle("/", mux) + return handler +}
A internal/routes/index_test.go

@@ -0,0 +1,103 @@

+package routes + +import ( + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "regexp" + "slices" + "strings" + "testing" + "time" + + gogit "github.com/go-git/go-git/v5" + "github.com/go-git/go-git/v5/plumbing/object" + "gitview/internal/config" +) + +// Exercise configured ordering through YAML loading and rendered repository cards. +func TestIndexPinnedOrder(t *testing.T) { + c, _ := testWorkspace(t) + source := filepath.Join(c.Repo.ScanPath, "sample") + for name, day := range map[string]int{"first": 1, "second.git": 5, "recent": 10, "hidden": 20, "ignored": 25} { + path := filepath.Join(c.Repo.ScanPath, name) + repo, err := gogit.PlainClone(path, false, &gogit.CloneOptions{URL: source}) + if err != nil { + t.Fatal(err) + } + wt, err := repo.Worktree() + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(path, "README.md"), []byte(name), 0644); err != nil { + t.Fatal(err) + } + if _, err := wt.Add("README.md"); err != nil { + t.Fatal(err) + } + author := &object.Signature{Name: "Test Author", Email: "author@example.com", When: time.Date(2026, 1, day, 12, 0, 0, 0, time.UTC)} + if _, err := wt.Commit("Update "+name, &gogit.CommitOptions{Author: author}); err != nil { + t.Fatal(err) + } + } + cards := regexp.MustCompile(`data-repo="([^"]+)"`) + for _, tt := range []struct { + name string + pinned, want []string + }{ + {"default", nil, []string{"recent", "second", "sample", "first"}}, + {"configured order", []string{"first", "second"}, []string{"first", "second", "recent", "sample"}}, + {"suffixes duplicates missing and hidden", []string{"hidden", "ignored", "missing", "second.git", "first", "second"}, []string{"second", "first", "recent", "sample"}}, + } { + t.Run(tt.name, func(t *testing.T) { + pinned, err := json.Marshal(tt.pinned) + if err != nil { + t.Fatal(err) + } + path := filepath.Join(t.TempDir(), "config.yaml") + yaml := fmt.Sprintf("repo:\n scanPath: %q\n pinned: %s\n unlisted: [hidden]\n ignore: [ignored]\ndirs:\n templates: %q\n static: %q\n", c.Repo.ScanPath, pinned, c.Dirs.Templates, c.Dirs.Static) + if err := os.WriteFile(path, []byte(yaml), 0644); err != nil { + t.Fatal(err) + } + loaded, err := config.Read(path) + if err != nil { + t.Fatal(err) + } + w := httptest.NewRecorder() + Handlers(loaded).ServeHTTP(w, httptest.NewRequest("GET", "/", nil)) + if w.Code != http.StatusOK { + t.Fatalf("index status: %d", w.Code) + } + var got []string + for _, match := range cards.FindAllStringSubmatch(w.Body.String(), -1) { + got = append(got, match[1]) + } + if !slices.Equal(got, tt.want) { + t.Fatalf("repository order: %v; want %v", got, tt.want) + } + }) + } +} + +func TestRepositoryOwnerFromConfig(t *testing.T) { + c, _ := testWorkspace(t) + path := filepath.Join(t.TempDir(), "config.yaml") + yaml := fmt.Sprintf("repo:\n scanPath: %q\n mainBranch: [main]\ndirs:\n templates: %q\n static: %q\nmeta:\n title: git.example.com\n username: arnaucube\n", c.Repo.ScanPath, c.Dirs.Templates, c.Dirs.Static) + if err := os.WriteFile(path, []byte(yaml), 0644); err != nil { + t.Fatal(err) + } + loaded, err := config.Read(path) + if err != nil { + t.Fatal(err) + } + for _, path := range []string{"/sample", "/sample/tree/main/", "/sample/blob/main/src/main.go", "/sample/log/main", "/sample/refs/"} { + w := httptest.NewRecorder() + Handlers(loaded).ServeHTTP(w, httptest.NewRequest("GET", path, nil)) + if w.Code != http.StatusOK || !strings.Contains(w.Body.String(), `<a href="/">arnaucube</a>`) { + t.Errorf("%s: missing configured owner (status %d)", path, w.Code) + } + } +}
A internal/routes/languages_test.go

@@ -0,0 +1,74 @@

+package routes + +import ( + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + "time" + + gogit "github.com/go-git/go-git/v5" + "github.com/go-git/go-git/v5/plumbing" + "github.com/go-git/go-git/v5/plumbing/object" +) + +// Both views must describe the selected default branch, even when HEAD differs. +func TestLanguageViewsUseDefaultBranch(t *testing.T) { + c, _ := testWorkspace(t) + path := filepath.Join(c.Repo.ScanPath, "sample") + repo, err := gogit.PlainOpen(path) + if err != nil { + t.Fatal(err) + } + if err := repo.Storer.SetReference(plumbing.NewSymbolicReference(plumbing.HEAD, plumbing.NewBranchReferenceName("develop"))); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(path, "script.py"), []byte(strings.Repeat("print('hello')\n", 100)), 0644); err != nil { + t.Fatal(err) + } + wt, err := repo.Worktree() + if err != nil { + t.Fatal(err) + } + if _, err := wt.Add("script.py"); err != nil { + t.Fatal(err) + } + if _, err := wt.Commit("Add Python on develop", &gogit.CommitOptions{Author: &object.Signature{Name: "Test", Email: "test@example.com", When: time.Now()}}); err != nil { + t.Fatal(err) + } + for _, branch := range []string{"main", "develop"} { + c.Repo.MainBranch = []string{branch} + handler := Handlers(c) + for _, url := range []string{"/", "/sample"} { + w := httptest.NewRecorder() + handler.ServeHTTP(w, httptest.NewRequest("GET", url, nil)) + body := w.Body.String() + if w.Code != 200 { + t.Fatalf("%s on %s: status %d", url, branch, w.Code) + } + if strings.Contains(body, "ZgotmplZ") { + t.Fatal("template rejected language colors or bar sizes") + } + if url == "/" { + language := "Go" + if branch == "develop" { + language = "Python" + } + if !strings.Contains(body, `class="repo-language"`) || !strings.Contains(body, `</span>`+language+`</span>`) { + t.Errorf("index on %s: missing primary language %s", branch, language) + } + } else { + if !strings.Contains(body, `class="language-bar"`) || !strings.Contains(body, `class="language-legend"`) { + t.Fatal("repository page missing language breakdown") + } + if branch == "main" && !strings.Contains(body, "100.0%") { + t.Fatal("single language share should be 100%") + } + if branch == "develop" && !strings.Contains(body, "<strong>Python</strong>") { + t.Fatal("branch language statistics were not refreshed") + } + } + } + } +}
A internal/routes/routes.go

@@ -0,0 +1,541 @@

+package routes + +import ( + "compress/gzip" + "fmt" + "html/template" + "log" + "net/http" + "os" + "path/filepath" + "sort" + "strconv" + "strings" + "time" + + securejoin "github.com/cyphar/filepath-securejoin" + "github.com/dustin/go-humanize" + "github.com/microcosm-cc/bluemonday" + "github.com/russross/blackfriday/v2" + "gitview/internal/config" + "gitview/internal/git" +) + +type deps struct { + c *config.Config +} + +func (d *deps) Index(w http.ResponseWriter, r *http.Request) { + dirs, err := os.ReadDir(d.c.Repo.ScanPath) + if err != nil { + d.Write500(w) + log.Printf("reading scan path: %s", err) + return + } + + type info struct { + DisplayName, Name, Desc, Idle string + Languages []git.LanguageStat + d time.Time + } + + infos := []info{} + + for _, dir := range dirs { + name := dir.Name() + if !dir.IsDir() || d.isIgnored(name) || d.isUnlisted(name) { + continue + } + + path, err := securejoin.SecureJoin(d.c.Repo.ScanPath, name) + if err != nil { + log.Printf("securejoin error: %v", err) + d.Write404(w) + return + } + + gr, err := git.Open(path, "") + if err != nil { + log.Println(err) + continue + } + + // Use the same default branch as the repository page for language statistics. + mainBranch, err := gr.FindMainBranch(d.c.Repo.MainBranch) + if err != nil { + log.Println(err) + continue + } + gr, err = git.Open(path, mainBranch) + if err != nil { + log.Println(err) + continue + } + languages, err := gr.Languages() + if err != nil { + log.Printf("languages for %s: %v", name, err) + } + + c, err := gr.LastCommit() + if err != nil { + log.Println(err) + continue + } + + infos = append(infos, info{ + DisplayName: getDisplayName(name), + Name: name, + Desc: getDescription(path), + Languages: languages, + Idle: humanize.Time(c.Commit().Author.When), + d: c.Commit().Author.When, + }) + } + + // Pin configured repositories first; duplicates keep their first listed position. + pinned := make(map[string]int, len(d.c.Repo.Pinned)) + for i, name := range d.c.Repo.Pinned { + name = getDisplayName(name) + if _, exists := pinned[name]; !exists { + pinned[name] = i + } + } + sort.SliceStable(infos, func(i, j int) bool { + iRank, iPinned := pinned[infos[i].DisplayName] + jRank, jPinned := pinned[infos[j].DisplayName] + if iPinned != jPinned { + return iPinned + } + if iPinned && iRank != jRank { + return iRank < jRank + } + return infos[j].d.Before(infos[i].d) + }) + + tpath := filepath.Join(d.c.Dirs.Templates, "*") + t := template.Must(template.ParseGlob(tpath)) + + data := make(map[string]interface{}) + data["meta"] = d.c.Meta + data["info"] = infos + data["total"] = len(infos) + data["pinned"] = len(pinned) > 0 + + if err := t.ExecuteTemplate(w, "index", data); err != nil { + log.Println(err) + return + } +} + +func (d *deps) RepoIndex(w http.ResponseWriter, r *http.Request) { + name := r.PathValue("name") + if d.isIgnored(name) { + d.Write404(w) + return + } + name = filepath.Clean(name) + path, err := securejoin.SecureJoin(d.c.Repo.ScanPath, name) + if err != nil { + log.Printf("securejoin error: %v", err) + d.Write404(w) + return + } + + gr, err := git.Open(path, "") + if err != nil { + d.Write404(w) + return + } + + mainBranch, err := gr.FindMainBranch(d.c.Repo.MainBranch) + if err != nil { + d.Write500(w) + log.Println(err) + return + } + + // Resolve the selected default branch before reading its files and history. + gr, err = git.Open(path, mainBranch) + if err != nil { + d.Write404(w) + return + } + + commits, err := gr.Commits() + if err != nil { + d.Write500(w) + log.Println(err) + return + } + + var readmeContent template.HTML + for _, readme := range d.c.Repo.Readme { + ext := filepath.Ext(readme) + content, _ := gr.FileContent(readme) + if len(content) > 0 { + switch ext { + case ".md", ".mkd", ".markdown": + unsafe := blackfriday.Run( + []byte(content), + blackfriday.WithExtensions(blackfriday.CommonExtensions), + ) + html := bluemonday.UGCPolicy().SanitizeBytes(unsafe) + readmeContent = template.HTML(html) + default: + safe := bluemonday.UGCPolicy().SanitizeBytes([]byte(content)) + readmeContent = template.HTML( + fmt.Sprintf(`<pre>%s</pre>`, safe), + ) + } + break + } + } + + if readmeContent == "" { + log.Printf("no readme found for %s", name) + } + + tpath := filepath.Join(d.c.Dirs.Templates, "*") + t := template.Must(template.ParseGlob(tpath)) + + files, err := gr.FileTree("") + if err != nil { + d.Write500(w) + return + } + branches, _ := gr.Branches() + tags, _ := gr.Tags() + + data := make(map[string]any) + data["name"] = name + data["displayname"] = getDisplayName(name) + data["ref"] = mainBranch + data["readme"] = readmeContent + data["commits"] = commits + data["desc"] = getDescription(path) + data["servername"] = d.c.Server.Name + data["meta"] = d.c.Meta + data["gomod"] = isGoModule(gr) + data["files"] = files + data["branches"] = branches + data["tags"] = tags + data["commitcount"] = len(commits) + data["languages"], err = gr.Languages() + if err != nil { + log.Printf("languages for %s: %v", name, err) + } + + if err := t.ExecuteTemplate(w, "repo", data); err != nil { + log.Println(err) + return + } +} + +func (d *deps) RepoTree(w http.ResponseWriter, r *http.Request) { + name := r.PathValue("name") + if d.isIgnored(name) { + d.Write404(w) + return + } + treePath := r.PathValue("rest") + ref := r.PathValue("ref") + + name = filepath.Clean(name) + path, err := securejoin.SecureJoin(d.c.Repo.ScanPath, name) + if err != nil { + log.Printf("securejoin error: %v", err) + d.Write404(w) + return + } + gr, err := git.Open(path, ref) + if err != nil { + d.Write404(w) + return + } + + files, err := gr.FileTree(treePath) + if err != nil { + d.Write404(w) + return + } + + data := make(map[string]any) + data["name"] = name + data["displayname"] = getDisplayName(name) + data["ref"] = ref + data["parent"] = treePath + data["desc"] = getDescription(path) + data["dotdot"] = filepath.Dir(treePath) + if data["dotdot"] == "." { + data["dotdot"] = "" + } + data["branches"], _ = gr.Branches() + data["commits"], _ = gr.Commits() + + d.listFiles(files, data, w) +} + +func (d *deps) FileContent(w http.ResponseWriter, r *http.Request) { + var raw bool + if rawParam, err := strconv.ParseBool(r.URL.Query().Get("raw")); err == nil { + raw = rawParam + } + + name := r.PathValue("name") + if d.isIgnored(name) { + d.Write404(w) + return + } + treePath := r.PathValue("rest") + ref := r.PathValue("ref") + + name = filepath.Clean(name) + path, err := securejoin.SecureJoin(d.c.Repo.ScanPath, name) + if err != nil { + log.Printf("securejoin error: %v", err) + d.Write404(w) + return + } + + gr, err := git.Open(path, ref) + if err != nil { + d.Write404(w) + return + } + + contents, err := gr.FileContent(treePath) + if err != nil { + d.Write404(w) + return + } + data := make(map[string]any) + data["name"] = name + data["displayname"] = getDisplayName(name) + data["ref"] = ref + data["desc"] = getDescription(path) + data["path"] = treePath + + if raw { + d.showRaw(contents, w) + } else { + if d.c.Meta.SyntaxHighlight == "" { + d.showFile(contents, data, w) + } else { + d.showFileWithHighlight(treePath, contents, data, w) + } + } +} + +func (d *deps) Archive(w http.ResponseWriter, r *http.Request) { + name := r.PathValue("name") + if d.isIgnored(name) { + d.Write404(w) + return + } + + file := r.PathValue("file") + + // TODO: extend this to add more files compression (e.g.: xz) + if !strings.HasSuffix(file, ".tar.gz") { + d.Write404(w) + return + } + + ref := strings.TrimSuffix(file, ".tar.gz") + + // This allows the browser to use a proper name for the file when + // downloading + filename := fmt.Sprintf("%s-%s.tar.gz", name, ref) + setContentDisposition(w, filename) + setGZipMIME(w) + + path, err := securejoin.SecureJoin(d.c.Repo.ScanPath, name) + if err != nil { + log.Printf("securejoin error: %v", err) + d.Write404(w) + return + } + + gr, err := git.Open(path, ref) + if err != nil { + d.Write404(w) + return + } + + gw := gzip.NewWriter(w) + defer func() { _ = gw.Close() }() + + prefix := fmt.Sprintf("%s-%s", name, ref) + err = gr.WriteTar(gw, prefix) + if err != nil { + // once we start writing to the body we can't report error anymore + // so we are only left with printing the error. + log.Println(err) + return + } + + err = gw.Flush() + if err != nil { + // once we start writing to the body we can't report error anymore + // so we are only left with printing the error. + log.Println(err) + return + } +} + +func (d *deps) Log(w http.ResponseWriter, r *http.Request) { + name := r.PathValue("name") + if d.isIgnored(name) { + d.Write404(w) + return + } + ref := r.PathValue("ref") + + path, err := securejoin.SecureJoin(d.c.Repo.ScanPath, name) + if err != nil { + log.Printf("securejoin error: %v", err) + d.Write404(w) + return + } + + gr, err := git.Open(path, ref) + if err != nil { + d.Write404(w) + return + } + + commits, err := gr.Commits() + if err != nil { + d.Write500(w) + log.Println(err) + return + } + + tpath := filepath.Join(d.c.Dirs.Templates, "*") + t := template.Must(template.ParseGlob(tpath)) + + data := make(map[string]interface{}) + data["commits"] = commits + data["meta"] = d.c.Meta + data["name"] = name + data["displayname"] = getDisplayName(name) + data["ref"] = ref + data["desc"] = getDescription(path) + data["log"] = true + + if err := t.ExecuteTemplate(w, "log", data); err != nil { + log.Println(err) + return + } +} + +func (d *deps) Diff(w http.ResponseWriter, r *http.Request) { + name := r.PathValue("name") + if d.isIgnored(name) { + d.Write404(w) + return + } + ref := r.PathValue("ref") + + path, err := securejoin.SecureJoin(d.c.Repo.ScanPath, name) + if err != nil { + log.Printf("securejoin error: %v", err) + d.Write404(w) + return + } + gr, err := git.Open(path, ref) + if err != nil { + d.Write404(w) + return + } + + diff, err := gr.Diff() + if err != nil { + d.Write500(w) + log.Println(err) + return + } + + tpath := filepath.Join(d.c.Dirs.Templates, "*") + t := template.Must(template.ParseGlob(tpath)) + + data := make(map[string]interface{}) + + data["commit"] = diff.Commit + data["stat"] = diff.Stat + data["diff"] = diff.Diff + data["meta"] = d.c.Meta + data["name"] = name + data["displayname"] = getDisplayName(name) + data["ref"] = ref + data["desc"] = getDescription(path) + + if err := t.ExecuteTemplate(w, "commit", data); err != nil { + log.Println(err) + return + } +} + +func (d *deps) Refs(w http.ResponseWriter, r *http.Request) { + name := r.PathValue("name") + if d.isIgnored(name) { + d.Write404(w) + return + } + + path, err := securejoin.SecureJoin(d.c.Repo.ScanPath, name) + if err != nil { + log.Printf("securejoin error: %v", err) + d.Write404(w) + return + } + + gr, err := git.Open(path, "") + if err != nil { + d.Write404(w) + return + } + + tags, err := gr.Tags() + if err != nil { + // Non-fatal, we *should* have at least one branch to show. + log.Println(err) + } + + branches, err := gr.Branches() + if err != nil { + log.Println(err) + d.Write500(w) + return + } + + tpath := filepath.Join(d.c.Dirs.Templates, "*") + t := template.Must(template.ParseGlob(tpath)) + + data := make(map[string]interface{}) + + data["meta"] = d.c.Meta + data["name"] = name + data["displayname"] = getDisplayName(name) + data["branches"] = branches + data["tags"] = tags + data["isrefs"] = true + data["desc"] = getDescription(path) + + if err := t.ExecuteTemplate(w, "refs", data); err != nil { + log.Println(err) + return + } +} + +func (d *deps) ServeStatic(w http.ResponseWriter, r *http.Request) { + f := r.PathValue("file") + f = filepath.Clean(f) + f, err := securejoin.SecureJoin(d.c.Dirs.Static, f) + if err != nil { + d.Write404(w) + return + } + + http.ServeFile(w, r, f) +}
A internal/routes/routes_test.go

@@ -0,0 +1,238 @@

+package routes + +import ( + "archive/tar" + "compress/gzip" + "io" + "net/http" + "net/http/httptest" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + "time" + + gogit "github.com/go-git/go-git/v5" + "github.com/go-git/go-git/v5/plumbing" + "github.com/go-git/go-git/v5/plumbing/object" + "gitview/internal/config" +) + +// testWorkspace uses real Git objects so the handlers exercise repository behavior. +func testWorkspace(t *testing.T) (*config.Config, string) { + t.Helper() + scan := t.TempDir() + path := filepath.Join(scan, "sample") + repo, err := gogit.PlainInit(path, false) + if err != nil { + t.Fatal(err) + } + if err := repo.Storer.SetReference(plumbing.NewSymbolicReference(plumbing.HEAD, plumbing.NewBranchReferenceName("main"))); err != nil { + t.Fatal(err) + } + wt, err := repo.Worktree() + if err != nil { + t.Fatal(err) + } + for name, content := range map[string]string{"README.md": "# Sample project\n\nA useful README.\n<script>alert('unsafe')</script>", "src/main.go": "package main\n\nfunc main() {}\n", "plain.unknownextension": "hello <world>\n"} { + if err := os.MkdirAll(filepath.Dir(filepath.Join(path, name)), 0755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(path, name), []byte(content), 0644); err != nil { + t.Fatal(err) + } + } + if _, err := wt.Add("."); err != nil { + t.Fatal(err) + } + signature := &object.Signature{Name: "Test Author", Email: "author@example.com", When: time.Date(2026, 1, 2, 12, 0, 0, 0, time.UTC)} + hash, err := wt.Commit("Initial project", &gogit.CommitOptions{Author: signature}) + if err != nil { + t.Fatal(err) + } + if _, err := repo.CreateTag("v1.0.0", hash, nil); err != nil { + t.Fatal(err) + } + if err := repo.Storer.SetReference(plumbing.NewHashReference(plumbing.NewBranchReferenceName("develop"), hash)); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(path, "description"), []byte("A sample repository"), 0644); err != nil { + t.Fatal(err) + } + c := &config.Config{} + c.Repo.ScanPath = scan + c.Repo.MainBranch = []string{"main", "master"} + c.Repo.Readme = []string{"README.md"} + c.Dirs.Templates, _ = filepath.Abs("../../templates") + c.Dirs.Static, _ = filepath.Abs("../../static") + c.Meta.Title = "Gitview" + c.Meta.SyntaxHighlight = "github" + return c, hash.String() +} + +func TestRepositoryBrowsing(t *testing.T) { + c, hash := testWorkspace(t) + handler := Handlers(c) + for _, tt := range []struct { + path, contains string + status int + }{ + {"/", "sample", 200}, {"/sample", "Sample project", 200}, + {"/sample/tree/main/", "README.md", 200}, {"/sample/tree/main/src", "main.go", 200}, + {"/sample/blob/main/src/main.go", "chroma", 200}, {"/sample/blob/main/plain.unknownextension", "hello", 200}, + {"/sample/blob/main/src/main.go?raw=true", "package main", 200}, + {"/sample/log/main", "Initial project", 200}, {"/sample/commit/" + hash, "diff-add", 200}, + {"/sample/refs/", "v1.0.0", 200}, {"/static/app.js", "filterRepositories", 200}, + {"/missing", "could not be found", 404}, {"/sample/tree/missing/", "could not be found", 404}, + {"/sample/blob/main/missing", "could not be found", 404}, {"/sample/tree/main/missing", "could not be found", 404}, + {"/sample/unknown", "could not be found", 404}, {"/unknown/path/again", "could not be found", 404}, + } { + t.Run(tt.path, func(t *testing.T) { + w := httptest.NewRecorder() + handler.ServeHTTP(w, httptest.NewRequest("GET", tt.path, nil)) + if w.Code != tt.status || !strings.Contains(w.Body.String(), tt.contains) { + t.Fatalf("status %d, expected %d containing %q; body: %.500s", w.Code, tt.status, tt.contains, w.Body.String()) + } + }) + } + w := httptest.NewRecorder() + handler.ServeHTTP(w, httptest.NewRequest("GET", "/sample", nil)) + if strings.Contains(w.Body.String(), "<script>alert") { + t.Fatal("README contains executable script") + } + w = httptest.NewRecorder() + handler.ServeHTTP(w, httptest.NewRequest("GET", "/sample/blob/main/src/main.go?raw=true", nil)) + if got := w.Header().Get("Content-Type"); got != "text/plain; charset=utf-8" { + t.Fatalf("raw content type: %s", got) + } +} + +func TestArchiveContents(t *testing.T) { + c, _ := testWorkspace(t) + w := httptest.NewRecorder() + Handlers(c).ServeHTTP(w, httptest.NewRequest("GET", "/sample/archive/main.tar.gz", nil)) + if w.Code != 200 { + t.Fatalf("archive status: %d", w.Code) + } + gz, err := gzip.NewReader(w.Body) + if err != nil { + t.Fatal(err) + } + defer gz.Close() + tr := tar.NewReader(gz) + found := false + for { + h, err := tr.Next() + if err == io.EOF { + break + } + if err != nil { + t.Fatal(err) + } + if h.Name == "sample-main/src/main.go" { + b, err := io.ReadAll(tr) + if err != nil { + t.Fatal(err) + } + found = string(b) == "package main\n\nfunc main() {}\n" + } + } + if !found { + t.Fatal("archive did not contain expected source") + } +} + +func TestVisibilityAndPushPolicy(t *testing.T) { + c, _ := testWorkspace(t) + c.Repo.Unlisted = []string{"sample"} + handler := Handlers(c) + w := httptest.NewRecorder() + handler.ServeHTTP(w, httptest.NewRequest("GET", "/", nil)) + if strings.Contains(w.Body.String(), "data-repo=\"sample\"") { + t.Fatal("unlisted repository is visible") + } + w = httptest.NewRecorder() + handler.ServeHTTP(w, httptest.NewRequest("GET", "/sample", nil)) + if w.Code != 200 { + t.Fatal("unlisted repository is not accessible") + } + c.Repo.Ignore = []string{"sample"} + for _, path := range []string{"/sample", "/sample/info/refs?service=git-upload-pack", "/sample/archive/main.tar.gz"} { + w = httptest.NewRecorder() + handler.ServeHTTP(w, httptest.NewRequest("GET", path, nil)) + if w.Code != 404 { + t.Fatalf("ignored repo accessible: %s (%d)", path, w.Code) + } + } + for _, method := range []string{"GET", "POST"} { + w = httptest.NewRecorder() + handler.ServeHTTP(w, httptest.NewRequest(method, "/sample/git-receive-pack", nil)) + if w.Code != 400 { + t.Fatalf("push not rejected: %d", w.Code) + } + } +} + +func TestSmartHTTPClone(t *testing.T) { + if _, err := exec.LookPath("git"); err != nil { + t.Skip("git executable unavailable") + } + c, hash := testWorkspace(t) + server := httptest.NewServer(Handlers(c)) + defer server.Close() + target := filepath.Join(t.TempDir(), "clone") + cmd := exec.Command("git", "clone", server.URL+"/sample", target) + cmd.Env = append(os.Environ(), "GIT_CONFIG_NOSYSTEM=1", "GIT_CONFIG_GLOBAL=/dev/null", "GIT_TERMINAL_PROMPT=0") + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("clone: %v: %s", err, out) + } + repo, err := gogit.PlainOpen(target) + if err != nil { + t.Fatal(err) + } + head, err := repo.Head() + if err != nil { + t.Fatal(err) + } + if head.Hash().String() != hash { + t.Fatal("clone HEAD differs from source") + } + resp, err := http.Get(server.URL + "/sample/info/refs?extra=1&service=git-upload-pack") + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + if resp.Header.Get("Content-Type") != "application/x-git-upload-pack-advertisement" { + t.Fatal("smart HTTP query routing failed") + } +} + +func TestCustomDefaultBranchAndEmptyRepository(t *testing.T) { + c, hash := testWorkspace(t) + repo, err := gogit.PlainOpen(filepath.Join(c.Repo.ScanPath, "sample")) + if err != nil { + t.Fatal(err) + } + if err := repo.Storer.SetReference(plumbing.NewHashReference(plumbing.NewBranchReferenceName("trunk"), plumbing.NewHash(hash))); err != nil { + t.Fatal(err) + } + if err := repo.Storer.SetReference(plumbing.NewSymbolicReference(plumbing.HEAD, plumbing.NewBranchReferenceName("trunk"))); err != nil { + t.Fatal(err) + } + c.Repo.MainBranch = []string{"nonexistent"} + if _, err := gogit.PlainInit(filepath.Join(c.Repo.ScanPath, "empty"), false); err != nil { + t.Fatal(err) + } + handler := Handlers(c) + for _, path := range []string{"/", "/sample"} { + w := httptest.NewRecorder() + handler.ServeHTTP(w, httptest.NewRequest("GET", path, nil)) + if w.Code != 200 { + t.Fatalf("%s: status %d", path, w.Code) + } + if path == "/sample" && !strings.Contains(w.Body.String(), "/sample/tree/trunk/") { + t.Fatal("default branch did not fall back to HEAD") + } + } +}
A internal/routes/template.go

@@ -0,0 +1,150 @@

+package routes + +import ( + "bytes" + "html/template" + "io" + "log" + "net/http" + "path/filepath" + "strings" + + "github.com/alecthomas/chroma/v2/formatters/html" + "github.com/alecthomas/chroma/v2/lexers" + "github.com/alecthomas/chroma/v2/styles" + "gitview/internal/git" +) + +func (d *deps) Write404(w http.ResponseWriter) { + tpath := filepath.Join(d.c.Dirs.Templates, "*") + t := template.Must(template.ParseGlob(tpath)) + w.WriteHeader(http.StatusNotFound) + if err := t.ExecuteTemplate(w, "404", map[string]any{"meta": d.c.Meta}); err != nil { + log.Printf("404 template: %s", err) + } +} + +func (d *deps) Write500(w http.ResponseWriter) { + tpath := filepath.Join(d.c.Dirs.Templates, "*") + t := template.Must(template.ParseGlob(tpath)) + w.WriteHeader(http.StatusInternalServerError) + if err := t.ExecuteTemplate(w, "500", map[string]any{"meta": d.c.Meta}); err != nil { + log.Printf("500 template: %s", err) + } +} + +func (d *deps) listFiles(files []git.NiceTree, data map[string]any, w http.ResponseWriter) { + tpath := filepath.Join(d.c.Dirs.Templates, "*") + t := template.Must(template.ParseGlob(tpath)) + + data["files"] = files + data["meta"] = d.c.Meta + + if err := t.ExecuteTemplate(w, "tree", data); err != nil { + log.Println(err) + return + } +} + +func countLines(r io.Reader) (int, error) { + buf := make([]byte, 32*1024) + bufLen := 0 + count := 0 + nl := []byte{'\n'} + + for { + c, err := r.Read(buf) + if c > 0 { + bufLen += c + } + count += bytes.Count(buf[:c], nl) + + switch { + case err == io.EOF: + /* handle last line not having a newline at the end */ + if bufLen >= 1 && buf[(bufLen-1)%(32*1024)] != '\n' { + count++ + } + return count, nil + case err != nil: + return 0, err + } + } +} + +func (d *deps) showFileWithHighlight(name, content string, data map[string]any, w http.ResponseWriter) { + tpath := filepath.Join(d.c.Dirs.Templates, "*") + t := template.Must(template.ParseGlob(tpath)) + + lexer := lexers.Get(name) + if lexer == nil { + lexer = lexers.Fallback + } + + style := styles.Get(d.c.Meta.SyntaxHighlight) + if style == nil { + style = styles.Get("monokailight") + } + + formatter := html.New( + html.WithLineNumbers(true), + html.WithLinkableLineNumbers(true, "L"), + ) + + iterator, err := lexer.Tokenise(nil, content) + if err != nil { + d.Write500(w) + return + } + + var code bytes.Buffer + err = formatter.Format(&code, style, iterator) + if err != nil { + d.Write500(w) + return + } + + data["content"] = template.HTML(code.String()) + data["meta"] = d.c.Meta + data["chroma"] = true + + if err := t.ExecuteTemplate(w, "file", data); err != nil { + log.Println(err) + return + } +} + +func (d *deps) showFile(content string, data map[string]any, w http.ResponseWriter) { + tpath := filepath.Join(d.c.Dirs.Templates, "*") + t := template.Must(template.ParseGlob(tpath)) + + lc, err := countLines(strings.NewReader(content)) + if err != nil { + // Non-fatal, we'll just skip showing line numbers in the template. + log.Printf("counting lines: %s", err) + } + + lines := make([]int, lc) + if lc > 0 { + for i := range lines { + lines[i] = i + 1 + } + } + + data["linecount"] = lines + data["content"] = content + data["meta"] = d.c.Meta + data["chroma"] = false + + if err := t.ExecuteTemplate(w, "file", data); err != nil { + log.Println(err) + return + } +} + +func (d *deps) showRaw(content string, w http.ResponseWriter) { + w.Header().Set("Content-Type", "text/plain; charset=utf-8") + w.Header().Set("X-Content-Type-Options", "nosniff") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(content)) +}
A internal/routes/util.go

@@ -0,0 +1,62 @@

+package routes + +import ( + "net/http" + "os" + "path/filepath" + "strings" + + "gitview/internal/git" +) + +func isGoModule(gr *git.GitRepo) bool { + _, err := gr.FileContent("go.mod") + return err == nil +} + +func getDisplayName(name string) string { + return strings.TrimSuffix(name, ".git") +} + +func getDescription(path string) (desc string) { + db, err := os.ReadFile(filepath.Join(path, "description")) + if err == nil { + desc = string(db) + } else { + desc = "" + } + return +} + +func (d *deps) isUnlisted(name string) bool { + for _, i := range d.c.Repo.Unlisted { + if name == i { + return true + } + } + + return false +} + +func (d *deps) isIgnored(name string) bool { + for _, i := range d.c.Repo.Ignore { + if name == i { + return true + } + } + + return false +} + +func setContentDisposition(w http.ResponseWriter, name string) { + h := "inline; filename=\"" + name + "\"" + w.Header().Add("Content-Disposition", h) +} + +func setGZipMIME(w http.ResponseWriter) { + setMIME(w, "application/gzip") +} + +func setMIME(w http.ResponseWriter, mime string) { + w.Header().Add("Content-Type", mime) +}
A scripts/mirror-github.sh

@@ -0,0 +1,134 @@

+#!/usr/bin/env bash +# Create or refresh read-only mirrors of a GitHub user's public repositories. +set -euo pipefail + +usage() { + printf 'Usage: %s USERNAME [DIRECTORY]\n' "${0##*/}" + printf 'Defaults to this project\047s repos/ directory; use repo.scanPath for a custom location.\n' + printf 'Requires git, curl, python3, and flock. GITHUB_TOKEN is optional for API rate limits.\n' +} + +if [[ ${1:-} == --help || ${1:-} == -h ]]; then + usage + exit 0 +fi +if (( $# < 1 || $# > 2 )); then + usage >&2 + exit 1 +fi +username=$1 +if [[ ! $username =~ ^[a-zA-Z0-9][a-zA-Z0-9-]{0,38}$ ]]; then + printf 'Invalid GitHub username.\n' >&2 + exit 1 +fi +for dependency in git curl python3 flock; do + if ! command -v "$dependency" >/dev/null 2>&1; then + printf 'Missing dependency: %s\n' "$dependency" >&2 + exit 1 + fi +done + +project_dir=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd) +destination=${2:-"$project_dir/repos"} +mkdir -p -- "$destination" +destination=$(cd -- "$destination" && pwd) +# Lock the destination so scheduled runs cannot modify the same mirrors concurrently. +exec 9>"$destination/.github-mirror.lock" +if ! flock -n 9; then + printf 'Another mirror job is already running for %s.\n' "$destination" >&2 + exit 1 +fi +staging=$(mktemp -d "$destination/.github-mirror.XXXXXX") +trap 'rm -rf -- "$staging"' EXIT +trap 'exit 130' INT +trap 'exit 143' TERM +export GIT_TERMINAL_PROMPT=0 + +# Send optional credentials over stdin, never in curl's command-line arguments. +fetch_page() { + local url=$1 + { + if [[ -n ${GITHUB_TOKEN:-} ]]; then + printf 'Authorization: Bearer %s\n' "$GITHUB_TOKEN" + fi + } | curl --fail --silent --show-error --retry 3 --connect-timeout 15 --max-time 120 \ + --header @- --header 'Accept: application/vnd.github+json' \ + --header 'X-GitHub-Api-Version: 2026-03-10' \ + --output "$staging/page.json" "$url" +} + +# Only update matching mirrors; never convert or replace an unrelated local repository. +mirror_repo() { + local name=$1 branch=$2 description=$3 + local url="https://github.com/$username/$name.git" + local target="$destination/$name.git" + if [[ -e $target || -L $target ]]; then + if [[ -L $target || ! -d $target ]] || + [[ $(git -C "$target" rev-parse --is-bare-repository 2>/dev/null) != true ]] || + [[ $(git -C "$target" config --get remote.origin.mirror) != true ]] || + [[ $(git -C "$target" config --get-all remote.origin.fetch) != '+refs/*:refs/*' ]] || + [[ $(git -C "$target" config --get remote.origin.url) != "$url" ]]; then + printf 'Skipping %s: destination is not a matching GitHub mirror.\n' "$name" >&2 + return 1 + fi + printf 'Updating %s/%s\n' "$username" "$name" + git -C "$target" fetch --prune origin || return 1 + else + printf 'Cloning %s/%s\n' "$username" "$name" + git clone --mirror -- "$url" "$staging/clone" || return 1 + mv -T -- "$staging/clone" "$target" || return 1 + fi + # Fetch does not update symbolic HEAD when GitHub changes the default branch. + if [[ -n $branch ]] && git -C "$target" show-ref --verify --quiet "refs/heads/$branch"; then + git -C "$target" symbolic-ref HEAD "refs/heads/$branch" || return 1 + fi + printf '%s\n' "$description" > "$target/description" || return 1 +} + +page=1 +mirrored=0 +failed=0 +while :; do + if ! fetch_page "https://api.github.com/users/$username/repos?type=owner&sort=full_name&direction=asc&per_page=100&page=$page"; then + printf 'GitHub API request failed; check the username, network, and API rate limit.\n' >&2 + exit 1 + fi + # NUL-separated fields preserve spaces, tabs, and multiline descriptions safely. + python3 - "$staging/page.json" "$staging/records" "$staging/count" <<'PY' +import json +import re +import sys +from pathlib import Path + +repos = json.loads(Path(sys.argv[1]).read_text()) +if not isinstance(repos, list): + raise SystemExit("Expected a repository list from GitHub.") +with open(sys.argv[2], "wb") as records: + for repo in repos: + if repo.get("private") is not False: + continue + name = repo["name"] + if not re.fullmatch(r"[A-Za-z0-9_.-]+", name) or name in (".", ".."): + raise SystemExit("Invalid repository name from GitHub.") + for value in (name, repo.get("default_branch") or "", repo.get("description") or ""): + records.write(value.replace("\0", "").encode() + b"\0") +Path(sys.argv[3]).write_text(str(len(repos))) +PY + while IFS= read -r -d '' name && IFS= read -r -d '' branch && IFS= read -r -d '' description; do + if mirror_repo "$name" "$branch" "$description"; then + mirrored=$((mirrored + 1)) + else + printf 'Failed to mirror %s/%s; continuing.\n' "$username" "$name" >&2 + # Remove only this job's incomplete clone before processing the next repository. + rm -rf -- "$staging/clone" + failed=$((failed + 1)) + fi + done < "$staging/records" + count=$(cat "$staging/count") + if (( count < 100 )); then + break + fi + page=$((page + 1)) +done +printf 'Mirrored %d repositories to %s; %d failed.\n' "$mirrored" "$destination" "$failed" +(( failed == 0 ))
A scripts/setup-demo.sh

@@ -0,0 +1,15 @@

+#!/bin/sh +# Populate a local workspace with actual, read-only example repositories. +set -eu +cd "$(dirname "$0")/.." +mkdir -p repos +clone_repo() { + if [ ! -d "repos/$1.git" ]; then + git clone --bare "$2" "repos/$1.git" + printf '%s\n' "$3" > "repos/$1.git/description" + fi +} +clone_repo babyjubjub-rs https://github.com/arnaucube/babyjubjub-rs.git 'BabyJubJub elliptic curve implementation and EdDSA signature scheme in Rust' +clone_repo protogalaxy-poc https://github.com/arnaucube/protogalaxy-poc.git 'ProtoGalaxy folding scheme PoC implementation https://eprint.iacr.org/2023/1106.pdf' +clone_repo fri-commitment https://github.com/arnaucube/fri-commitment.git 'FRI low-degree-testing & polynomial commitment scheme' +clone_repo go-snark https://github.com/arnaucube/go-snark.git 'zkSNARK library implementation in Go from scratch (compiler, setup, prover, verifier)'
A scripts/tests/test_mirror_github.py

@@ -0,0 +1,203 @@

+"""Exercise the mirror script against local Git repositories and a stub GitHub API.""" +import json +import os +from pathlib import Path +import subprocess +import tempfile +import unittest + +SCRIPT = Path(__file__).resolve().parents[1] / "mirror-github.sh" + + +class MirrorGitHubTests(unittest.TestCase): + def setUp(self): + self.temp = tempfile.TemporaryDirectory() + self.addCleanup(self.temp.cleanup) + self.root = Path(self.temp.name) + self.sources = self.root / "sources" + self.sources.mkdir() + self.destination = self.root / "served repos" + self.fixtures = self.root / "pages" + self.fixtures.mkdir() + tools = self.root / "tools" + tools.mkdir() + # Stub only HTTP discovery; every clone/fetch uses real Git with local URL rewriting. + curl = tools / "curl" + curl.write_text('''#!/usr/bin/env python3 +import json, os, sys +from pathlib import Path +from urllib.parse import parse_qs, urlparse +if os.environ.get("TEST_API_FAILURE"): + sys.exit(22) +args = sys.argv[1:] +assert not any("test-token" in arg for arg in args) +headers = sys.stdin.read() +if os.environ.get("GITHUB_TOKEN"): + assert "Authorization: Bearer test-token" in headers +url = urlparse(args[-1]) +assert url.path == "/users/testuser/repos" +query = parse_qs(url.query) +assert query["per_page"] == ["100"] +page = query["page"][0] +with open(os.environ["TEST_API_CALLS"], "a") as calls: + calls.write(page + "\\n") +Path(args[args.index("--output") + 1]).write_bytes( + (Path(os.environ["TEST_API_PAGES"]) / (page + ".json")).read_bytes()) +''') + curl.chmod(0o755) + self.env = os.environ.copy() + # Isolate Git configuration, identities, credentials, and URL rewrites from the host. + self.env.update({ + "PATH": str(tools) + os.pathsep + self.env["PATH"], + "GIT_CONFIG_NOSYSTEM": "1", + "GIT_CONFIG_GLOBAL": "/dev/null", + "GIT_CONFIG_COUNT": "1", + "GIT_CONFIG_KEY_0": f"url.{self.sources.as_uri()}/.insteadOf", + "GIT_CONFIG_VALUE_0": "https://github.com/testuser/", + "GIT_AUTHOR_NAME": "Mirror Test", + "GIT_AUTHOR_EMAIL": "mirror@example.com", + "GIT_COMMITTER_NAME": "Mirror Test", + "GIT_COMMITTER_EMAIL": "mirror@example.com", + "TEST_API_PAGES": str(self.fixtures), + "TEST_API_CALLS": str(self.root / "calls"), + "GITHUB_TOKEN": "test-token", + }) + self.env.pop("TEST_API_FAILURE", None) + + def git(self, directory, *args): + result = subprocess.run(["git", "-C", str(directory), *args], env=self.env, + text=True, capture_output=True, check=True) + return result.stdout.strip() + + def source(self, name, empty=False): + path = self.sources / (name + ".git") + path.mkdir() + self.git(path, "init", "--initial-branch=main") + if not empty: + (path / "README.md").write_text("# Example\n") + self.git(path, "add", "README.md") + self.git(path, "commit", "-m", "Initial source") + return path + + def page(self, repos, number=1): + (self.fixtures / f"{number}.json").write_text(json.dumps(repos)) + + def record(self, name, **overrides): + return {"name": name, "private": False, "default_branch": "main", + "description": "A description\twith tabs\nand newlines.", **overrides} + + def run_script(self, success=True): + result = subprocess.run([str(SCRIPT), "testuser", str(self.destination)], + env=self.env, text=True, capture_output=True, timeout=30) + if success: + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + else: + self.assertNotEqual(result.returncode, 0) + return result + + def test_clone_refresh_and_prune(self): + source = self.source("project") + initial_hash = self.git(source, "rev-parse", "HEAD") + self.git(source, "branch", "obsolete") + self.git(source, "tag", "v1") + self.page([self.record("project", fork=True, archived=True)]) + self.run_script() + mirror = self.destination / "project.git" + self.assertEqual(self.git(mirror, "rev-parse", "HEAD"), self.git(source, "rev-parse", "HEAD")) + self.assertEqual(self.git(mirror, "rev-parse", "--is-bare-repository"), "true") + self.assertEqual((mirror / "description").read_text(), self.record("project")["description"] + "\n") + self.git(source, "branch", "-m", "trunk") + self.git(source, "branch", "-D", "obsolete") + self.git(source, "tag", "-d", "v1") + (source / "README.md").write_text("# Changed\n") + self.git(source, "commit", "-am", "Upstream update") + self.page([self.record("project", default_branch="trunk", description=None)]) + self.run_script() + self.assertEqual(self.git(mirror, "symbolic-ref", "HEAD"), "refs/heads/trunk") + self.assertEqual(self.git(mirror, "rev-parse", "HEAD"), self.git(source, "rev-parse", "HEAD")) + self.assertEqual(self.git(mirror, "for-each-ref", "--format=%(refname)"), "refs/heads/trunk") + self.assertEqual((mirror / "description").read_text(), "\n") + self.git(source, "update-ref", "refs/heads/trunk", initial_hash) + self.run_script() + self.assertEqual(self.git(mirror, "rev-parse", "HEAD"), initial_hash) + + def test_pagination_and_public_only_filter(self): + self.source("last-page") + # A full first page must not stop pagination even if every entry is filtered out. + self.page([self.record(f"private-{i}", private=True) for i in range(100)]) + self.page([self.record("last-page")], number=2) + self.run_script() + self.assertEqual((self.root / "calls").read_text(), "1\n2\n") + self.assertEqual(sorted(p.name for p in self.destination.glob("*.git")), ["last-page.git"]) + + def test_preserves_unrelated_destination_and_continues(self): + self.source("project") + self.source("other") + existing = self.destination / "project.git" + existing.mkdir(parents=True) + (existing / "important.txt").write_text("Keep this file") + self.page([self.record("project"), self.record("other")]) + result = self.run_script(success=False) + self.assertIn("not a matching GitHub mirror", result.stderr) + self.assertEqual((existing / "important.txt").read_text(), "Keep this file") + self.assertTrue((self.destination / "other.git" / "HEAD").is_file()) + + def test_refuses_existing_bare_clones_and_foreign_mirrors(self): + self.destination.mkdir() + for name, mode in (("bare", "--bare"), ("foreign", "--mirror")): + source = self.source(name) + target = self.destination / (name + ".git") + self.git(self.root, "clone", mode, str(source), str(target)) + self.page([self.record("bare"), self.record("foreign")]) + self.run_script(success=False) + for name in ("bare", "foreign"): + target = self.destination / (name + ".git") + self.assertEqual(self.git(target, "config", "--get", "remote.origin.url"), + str(self.sources / (name + ".git"))) + self.assertEqual(self.git(target, "rev-parse", "HEAD"), + self.git(self.sources / (name + ".git"), "rev-parse", "HEAD")) + + def test_refuses_symlink_destination(self): + source = self.source("project") + self.destination.mkdir() + (self.destination / "project.git").symlink_to(source, target_is_directory=True) + self.page([self.record("project")]) + self.run_script(success=False) + self.assertFalse((source / "description").exists()) + + def test_empty_repository_and_failed_clone(self): + self.source("empty", empty=True) + self.source("working") + self.page([self.record("missing"), self.record("empty"), self.record("working")]) + result = self.run_script(success=False) + self.assertIn("Mirrored 2 repositories", result.stdout) + self.assertFalse((self.destination / "missing.git").exists()) + self.assertTrue((self.destination / "working.git" / "HEAD").is_file()) + self.assertEqual([p for p in self.destination.glob(".github-mirror.*") if p.is_dir()], []) + + def test_api_failure_does_not_touch_repositories(self): + self.env["TEST_API_FAILURE"] = "1" + result = self.run_script(success=False) + self.assertIn("GitHub API request failed", result.stderr) + self.assertEqual(list(self.destination.glob("*.git")), []) + self.assertEqual([p for p in self.destination.glob(".github-mirror.*") if p.is_dir()], []) + + def test_destination_lock(self): + self.destination.mkdir() + self.page([]) + import fcntl + with (self.destination / ".github-mirror.lock").open("w") as lock: + fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB) + result = self.run_script(success=False) + self.assertIn("already running", result.stderr) + self.assertFalse((self.root / "calls").exists()) + + def test_rejects_invalid_username(self): + result = subprocess.run([str(SCRIPT), "../unsafe", str(self.destination)], + env=self.env, text=True, capture_output=True) + self.assertNotEqual(result.returncode, 0) + self.assertFalse(self.destination.exists()) + + +if __name__ == "__main__": + unittest.main()
A static/app.js

@@ -0,0 +1,72 @@

+'use strict'; +// Search and sorting operate on the server-rendered repository list. +const search = document.querySelector('#repo-search'); +const globalSearch = document.querySelector('#global-search'); +const list = document.querySelector('#repo-list'); +const cards = [...document.querySelectorAll('[data-repo]')]; +function filterRepositories(value) { + const query = value.trim().toLowerCase(); + let count = 0; + for (const card of cards) { + card.hidden = !card.textContent.toLowerCase().includes(query); + if (!card.hidden) count++; + } + const counter = document.querySelector('#result-count'); + if (counter) counter.textContent = count; + const empty = document.querySelector('#no-results'); + if (empty) empty.hidden = count !== 0 || !query; + if (list) list.hidden = count === 0 && !!query; +} +search?.addEventListener('input', () => { if (globalSearch) globalSearch.value = search.value; filterRepositories(search.value); }); +globalSearch?.addEventListener('input', () => { + if (search) { search.value = globalSearch.value; filterRepositories(globalSearch.value); } +}); +// On repository pages, Enter carries the search back to the workspace. +globalSearch?.addEventListener('keydown', event => { + if (event.key === 'Enter' && !search) location.href = '/?q=' + encodeURIComponent(globalSearch.value); +}); +const initialQuery = new URLSearchParams(location.search).get('q'); +if (initialQuery && search) { search.value = initialQuery; globalSearch.value = initialQuery; filterRepositories(initialQuery); } +document.querySelector('#repo-sort')?.addEventListener('change', event => { + const byName = event.target.value === 'name'; + cards.sort((a, b) => byName ? a.dataset.repo.localeCompare(b.dataset.repo) : Number(a.dataset.index) - Number(b.dataset.index)); + for (const card of cards) list.append(card); + document.querySelector('#sort-description').textContent = byName ? 'name' : event.target.selectedOptions[0].dataset.description; +}); +document.querySelector('#view-toggle')?.addEventListener('click', event => { + const compact = list.classList.toggle('compact'); + event.currentTarget.setAttribute('aria-pressed', String(compact)); +}); +document.querySelector('#theme-toggle')?.addEventListener('click', () => { + const theme = document.documentElement.dataset.theme === 'dark' ? 'light' : 'dark'; + document.documentElement.dataset.theme = theme; + try { localStorage.setItem('gitview-theme', theme); } catch (_) { /* Theme still works without storage. */ } +}); +document.addEventListener('keydown', event => { + if (event.key === '/' && !/INPUT|TEXTAREA|SELECT/.test(document.activeElement.tagName)) { + event.preventDefault(); (search || globalSearch)?.focus(); + } + if (event.key === 'Escape') document.querySelectorAll('details[open]').forEach(item => item.open = false); +}); +document.addEventListener('click', event => { + document.querySelectorAll('details[open]').forEach(item => { if (!item.contains(event.target)) item.open = false; }); +}); +for (const input of document.querySelectorAll('[data-clone]')) input.value = location.origin + input.dataset.clone; +let toastTimer; +function toast(message) { + const element = document.querySelector('#toast'); + element.textContent = message; + element.classList.add('visible'); + clearTimeout(toastTimer); + toastTimer = setTimeout(() => element.classList.remove('visible'), 2500); +} +for (const button of document.querySelectorAll('[data-copy]')) button.addEventListener('click', async () => { + const input = document.getElementById(button.dataset.copy); + try { await navigator.clipboard.writeText(input.value); toast('Clone URL copied'); } + catch (_) { input.focus(); input.select(); toast('Select and copy the clone URL'); } +}); +document.querySelector('[data-scroll-readme]')?.addEventListener('click', event => { + event.preventDefault(); + const readme = document.querySelector('.readme-panel'); + if (readme) readme.scrollIntoView({ behavior: 'smooth' }); else toast('No README in this repository'); +});
A static/style.css

@@ -0,0 +1,12 @@

+@import url('https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;450;500;550;600;650;700&family=Manrope:wght@500;600;650;700;750;800&display=swap'); +:root{color-scheme:light;--bg:#fff;--subtle:#f7f8fa;--surface:#fff;--text:#252d38;--muted:#77808d;--border:#e6e9ee;--green:#23865c;--green-bg:#edf7f1;--link:#287bb5;--hover:#f8fafb;--shadow:0 8px 30px #202b3a15}html[data-theme=dark]{color-scheme:dark;--bg:#10161d;--subtle:#161e27;--surface:#151d26;--text:#dbe2ed;--muted:#8b98a8;--border:#2a3440;--green:#60bf92;--green-bg:#19352b;--link:#73b9ef;--hover:#1b2631;--shadow:0 8px 30px #0005}*{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--text);font:14px 'DM Sans',-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;line-height:1.6}a{color:var(--link);text-decoration:none}a:hover{text-decoration:underline}button,input,select{font:inherit}button,summary,select{cursor:pointer}input,select{color:var(--text)}button{color:inherit}button:focus-visible,a:focus-visible,summary:focus-visible{outline:3px solid #78b7e8;outline-offset:3px}input:focus{outline:none}input::placeholder{color:var(--muted)}.icon{width:18px;height:18px;flex-shrink:0;vertical-align:middle}.topbar{height:77px;border-bottom:1px solid var(--border);display:flex;align-items:center;padding:0 34px;gap:26px;background:var(--surface)}.brand{min-width:0;font-family:Manrope,sans-serif;font-size:25px;font-weight:800;letter-spacing:-1.2px;display:flex;align-items:center;gap:11px;color:var(--text)}.brand:hover{text-decoration:none}.brand-mark{display:block;flex-shrink:0;border-radius:9px;width:34px;height:34px;object-fit:contain}.brand-name{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.top-separator{width:1px;height:22px;background:var(--border)}.top-right{margin-left:auto;display:flex;gap:20px;align-items:center}.global-search{width:280px;height:35px;border:1px solid var(--border);border-radius:6px;display:flex;gap:9px;padding:0 10px;align-items:center;color:var(--muted);background:var(--subtle)}.global-search input{min-width:0;flex:1;font-size:12px;background:transparent;border:0}.global-search .icon{width:15px}kbd{font:11px monospace;border:1px solid var(--border);border-radius:3px;padding:0 4px;color:var(--muted);line-height:18px;background:var(--surface)}.icon-button{border:1px solid transparent;display:grid;place-items:center;border-radius:6px;background:transparent;min-width:32px;height:32px;color:var(--muted)}.icon-button:hover{background:var(--subtle);border-color:var(--border)}.avatar{width:34px;height:34px;display:inline-flex;align-items:center;justify-content:center;flex-shrink:0;border-radius:50%;font-size:13px;font-weight:700;color:#5d6b4e;background:#e5eddc;border:2px solid var(--surface)}.avatar.small{width:32px;height:32px}.avatar.tiny{width:24px;height:24px;font-size:10px}.workspace-layout{display:grid;grid-template-columns:250px minmax(0,1fr);min-height:calc(100vh - 77px)}.sidebar{border-right:1px solid var(--border);padding:30px 20px 16px;background:var(--subtle);position:relative;display:flex;flex-direction:column}.workspace-identity{display:flex;align-items:center;gap:11px;margin-bottom:35px;padding:0 4px}.workspace-avatar{width:39px;height:39px;display:grid;place-items:center;background:var(--surface);border:1px solid var(--border);border-radius:10px;color:var(--green)}.workspace-identity strong{display:block;font-size:13px;font-weight:650}.workspace-identity div>span{display:block;font-size:10px;color:var(--muted);margin-top:2px}.section-label{font-size:10px;font-weight:650;color:var(--muted);letter-spacing:1.4px;padding:0 12px;margin-bottom:12px}.side-link{height:42px;border-radius:6px;display:flex;align-items:center;gap:11px;padding:0 12px;color:var(--muted);font-size:12px;margin:2px 0}.side-link.selected{background:var(--green-bg);color:var(--green);font-weight:600}.side-link:hover,.side-repo:hover{background:var(--green-bg);text-decoration:none}.count{font-size:11px;line-height:21px;min-width:23px;text-align:center;background:var(--subtle);color:var(--muted);border:1px solid var(--border);border-radius:20px;padding:0 6px;font-weight:500;display:inline-block}.selected .count{background:#dcefe3;color:var(--green);border:0;margin-left:auto}.sidebar-divider{height:1px;background:var(--border);margin:24px 10px}.side-section-title{display:flex;justify-content:space-between}.side-repo{display:flex;align-items:center;gap:10px;padding:9px 12px;color:var(--muted);font-size:12px;border-radius:5px;overflow-wrap:anywhere}.side-repo .icon{width:15px}.sidebar-note{margin:40px 4px 25px;border:1px solid var(--border);background:var(--surface);border-radius:8px;padding:17px 14px;color:var(--muted);font-size:11px}.sidebar-note strong{display:block;color:var(--text);font-size:11px;margin-top:8px}.sidebar-note p{margin:7px 0 12px;line-height:1.8}.sidebar-note>span:last-child{font-size:10px}.status-dot{display:inline-block;width:6px;height:6px;border-radius:50%;background:#3ba874}.sidebar-bottom{margin-top:auto;padding:35px 12px 5px;display:flex;align-items:center;gap:8px;font-size:10px;color:var(--muted)}.sidebar-bottom .icon{width:14px}.workspace-main{max-width:1350px;width:100%;padding:29px 44px 0}.breadcrumb{color:var(--muted);font-size:11px;display:flex;gap:11px}.breadcrumb strong{font-weight:500;color:var(--text)}.breadcrumb span{color:#b8bec6}.page-title{display:flex;justify-content:space-between;align-items:center;margin:32px 0 28px}.eyebrow{font-size:9px;font-weight:700;letter-spacing:1.8px;color:var(--green)}h1,h2,h3,p{margin-top:0}h1{font-family:Manrope,sans-serif;letter-spacing:-1.1px;font-size:30px;line-height:1.4;font-weight:750;margin-bottom:9px}.page-title h1{margin-top:7px}.page-title p{color:var(--muted);font-size:12px;margin:0}.green-text{color:var(--green)}.hero-icon{width:66px;height:66px;border:1px solid var(--border);background:var(--subtle);display:grid;place-items:center;border-radius:16px;transform:rotate(-7deg);margin:4px 10px 0 20px;color:var(--green)}.hero-icon .icon{height:31px;width:31px}.overview-cards{display:grid;grid-template-columns:1fr 1fr 1fr;gap:14px;margin-bottom:36px}.overview-card{border:1px solid var(--border);border-radius:8px;min-height:91px;display:flex;align-items:center;gap:13px;padding:17px;position:relative}.stat-icon{width:34px;height:34px;border-radius:8px;display:grid;place-items:center;flex-shrink:0}.green{color:var(--green);background:var(--green-bg)}.blue{color:#538cbe;background:#edf4fc}.purple{color:#9075b6;background:#f3eff9}.overview-card div>span{display:block;color:var(--muted);font-size:10px}.overview-card strong{font-size:22px;display:block;line-height:1.5;font-family:Manrope,sans-serif}.overview-card .stat-word{font-family:inherit;font-size:11px;font-weight:550;margin-top:3px;letter-spacing:0}.stat-note{font-size:9px;color:var(--muted);margin-left:auto;align-self:flex-end;margin-bottom:6px;white-space:nowrap}.section-heading{display:flex;align-items:center;justify-content:space-between;gap:15px;margin-bottom:18px}.section-heading h2{font-family:Manrope,sans-serif;font-size:17px;letter-spacing:-.4px;margin:0;display:flex;align-items:center;gap:10px}.section-heading>.muted{font-size:10px}.muted{color:var(--muted)}.filter-bar{display:flex;align-items:center;gap:10px}.repo-search{display:flex;align-items:center;gap:10px;border:1px solid var(--border);border-radius:6px;padding:0 12px;height:38px;flex:1;color:var(--muted);box-shadow:0 1px 2px #00000003}.repo-search:focus-within,.global-search:focus-within{border-color:var(--green)}.repo-search .icon{width:16px}.repo-search input{border:0;background:transparent;min-width:0;flex:1;font-size:12px}.sort-label select{border:1px solid var(--border);border-radius:6px;height:38px;font-size:11px;padding:0 28px 0 12px;background:var(--subtle)}.view-toggle{height:38px;width:38px;border:1px solid var(--border)}.result-label{display:flex;justify-content:space-between;font-size:10px;color:var(--muted);padding:17px 0 12px}.result-label strong{color:var(--text);font-weight:500}.result-label>span:last-child{display:flex;align-items:center;gap:5px}.result-label .icon{width:12px;height:12px}.repository-list{border:1px solid var(--border);border-radius:8px;overflow:hidden}.repository-card{display:flex;align-items:flex-start;gap:16px;padding:23px 22px;background:var(--surface);border-bottom:1px solid var(--border);transition:background .15s}.repository-card:last-child{border-bottom:0}.repository-card:hover{background:var(--hover)}.repo-card-icon{width:36px;height:36px;border:1px solid var(--border);border-radius:8px;display:grid;place-items:center;color:var(--muted);background:var(--subtle);margin-top:3px}.repo-card-title{display:flex;align-items:center;gap:10px;margin-bottom:6px}.repo-card-title h3{margin:0;font-size:15px;font-weight:600;letter-spacing:-.2px}.badge{display:inline-flex;border:1px solid var(--border);color:var(--muted);border-radius:20px;padding:0 7px;font-size:9px;font-weight:500;line-height:18px}.repo-card-content{flex:1;min-width:0}.repo-card-content p{font-size:11px;color:var(--muted);margin-bottom:12px;max-width:700px}.repo-card-meta{display:flex;gap:12px;align-items:center;color:var(--muted);font-size:10px}.repo-card-meta>span{display:inline-flex;align-items:center;gap:6px}.repo-card-meta .icon{width:12px;height:12px}.meta-dot{opacity:.5}.repo-open{margin:auto 0 auto 12px;color:#9aa3ae}.repo-open .icon{width:15px}.compact .repository-card{padding:12px 18px}.compact .repo-card-content p,.compact .repo-card-meta{display:none}.compact .repo-card-title{margin-top:7px}.workspace-tip{margin-top:23px;padding:15px 0;display:flex;align-items:center;gap:10px;color:var(--muted);font-size:10px}.workspace-tip>.icon{width:16px;color:var(--green)}.workspace-tip strong{font-weight:500;color:var(--text)}.tip-key{margin-left:auto;font-family:monospace;font-size:10px;border:1px solid var(--border);border-radius:4px;padding:2px 7px;background:var(--subtle)}footer{border-top:1px solid var(--border);display:flex;align-items:center;gap:20px;margin-top:34px;padding:23px 0;font-size:10px;color:var(--muted)}.footer-brand{font-weight:600;display:flex;align-items:center;gap:7px}.footer-brand .icon{width:17px}.footer-end{margin-left:auto;display:flex;align-items:center;gap:8px}.empty-state{padding:55px 25px;text-align:center;color:var(--muted)}.empty-state>.icon{width:28px;height:28px;margin-bottom:15px}.empty-state h3{font-size:16px;color:var(--text);margin-bottom:6px}.empty-state code{font-size:12px}.repo-banner{background:var(--subtle);border-bottom:1px solid var(--border);padding:25px 40px 0}.repo-heading{display:flex;align-items:center;gap:12px;font-size:19px}.repo-heading>.icon{color:var(--muted)}.slash{color:var(--muted)}.repo-name{font-weight:650}.repo-heading-end{margin-left:auto;color:var(--muted);display:flex;gap:7px;align-items:center;font-size:11px}.repo-heading-end .icon{width:14px;color:var(--green)}.repo-nav{display:flex;gap:25px;margin-top:24px}.repo-nav>a{display:flex;align-items:center;gap:8px;padding:12px 5px;color:var(--text);font-size:12px;position:relative}.repo-nav>a.active:after{content:'';position:absolute;height:3px;background:#dd9572;bottom:-1px;left:0;right:0;border-radius:3px}.repo-nav .icon{width:16px;color:var(--muted)}.repo-main{max-width:1400px;margin:auto;padding:30px 40px}.repo-layout{display:grid;grid-template-columns:minmax(0,1fr) 250px;gap:32px}.repo-content{min-width:0}.repo-toolbar{display:flex;align-items:center;gap:13px;margin-bottom:18px}.button{border:1px solid var(--border);border-radius:6px;background:var(--subtle);display:inline-flex;align-items:center;justify-content:center;gap:8px;min-height:33px;padding:5px 12px;color:var(--text);font-size:12px;white-space:nowrap;font-weight:500}.button:hover{filter:brightness(.97);text-decoration:none}.button .icon{width:15px;height:15px}.primary{background:#258254;color:white;border-color:#23784f;box-shadow:0 1px 1px #0001}.toolbar-link{display:flex;align-items:center;gap:5px;font-size:11px;white-space:nowrap}.toolbar-link .icon{width:15px}.toolbar-spacer{flex:1}.chevron{margin-left:10px;font-size:13px}.dropdown{position:relative}.dropdown summary{list-style:none}.dropdown summary::-webkit-details-marker{display:none}.dropdown-menu{position:absolute;top:calc(100% + 9px);left:0;background:var(--surface);border:1px solid var(--border);border-radius:8px;min-width:230px;box-shadow:var(--shadow);z-index:20;padding:8px 0}.dropdown-menu a{display:flex;align-items:center;gap:9px;font-size:12px;padding:10px 16px;color:var(--text)}.dropdown-menu a:hover{background:var(--subtle);text-decoration:none}.menu-title{display:flex;gap:8px;align-items:center;font-size:12px;font-weight:600;padding:8px 16px;border-bottom:1px solid var(--border)}.clone-menu{left:auto;right:0;width:340px}.clone-menu p{padding:12px 16px 0;color:var(--muted);font-size:11px}.field-label{font-size:11px;font-weight:600;display:block;padding:0 16px 5px}.copy-field{display:flex;margin:0 16px 15px;border:1px solid var(--border);border-radius:5px;overflow:hidden}.copy-field input{width:100%;min-width:0;background:var(--subtle);border:0;padding:7px;font-size:11px}.copy-field button{border-left:1px solid var(--border);border-radius:0}.panel{border:1px solid var(--border);border-radius:7px;overflow:hidden;background:var(--surface)}.latest-commit{display:flex;align-items:center;gap:9px;padding:13px 16px;background:var(--subtle);border-bottom:1px solid var(--border);font-size:11px}.latest-commit strong{font-weight:600;white-space:nowrap}.commit-subject{color:var(--muted);text-overflow:ellipsis;overflow:hidden;white-space:nowrap;flex:1;min-width:0}.hash{font-family:monospace;font-size:11px;white-space:nowrap}.file-row{display:flex;align-items:center;padding:10px 17px;gap:12px;color:var(--text);border-bottom:1px solid var(--border);font-size:12px;min-height:43px}.file-row:last-child{border-bottom:0}.file-row:hover{background:var(--hover);text-decoration:none}.file-row:hover>span:first-of-type{color:var(--link);text-decoration:underline}.file-row>.icon{color:#69a2ce;width:17px}.file-row:has(.file-kind)>.icon{color:#80909f}.file-row .file-kind{margin-left:auto;font-size:11px;color:var(--muted)}.file-size{font-size:10px;color:var(--muted);width:100px;text-align:right;font-family:monospace}.readme-panel{margin-top:22px}.panel-heading{padding:12px 17px;background:var(--subtle);border-bottom:1px solid var(--border);font-weight:550;font-size:12px;display:flex;align-items:center;gap:9px}.panel-heading .button{margin-left:auto}.markdown-body{padding:30px 36px;overflow-wrap:anywhere;font-size:14px}.markdown-body h1{border-bottom:1px solid var(--border);padding-bottom:12px;font-size:29px}.markdown-body h2{border-bottom:1px solid var(--border);padding-bottom:10px;margin-top:28px;font-size:22px}.markdown-body h3{font-size:18px;margin-top:22px}.markdown-body img{max-width:100%}.markdown-body pre{overflow:auto;padding:16px;border-radius:6px;background:var(--subtle);font-size:12px}.markdown-body code{background:var(--subtle);border-radius:3px;padding:2px 4px}.markdown-body pre code{padding:0}.markdown-body blockquote{border-left:3px solid var(--border);padding-left:17px;margin-left:0;color:var(--muted)}.markdown-body table{border-collapse:collapse;display:block;overflow:auto}.markdown-body td,.markdown-body th{padding:7px 12px;border:1px solid var(--border)}.markdown-body tr:nth-child(even){background:var(--subtle)}.repo-about{padding-top:3px;font-size:12px}.repo-about h2{font-size:15px;margin-bottom:12px}.repo-about>p{font-size:13px;line-height:1.8;margin-bottom:20px}.repo-about a{display:flex;align-items:center;gap:8px;margin:10px 0;color:var(--muted);font-size:12px}.repo-about .icon{width:15px}.about-section{border-top:1px solid var(--border);margin-top:24px;padding-top:21px}.about-section h2{display:flex;gap:8px;align-items:center}.path-heading{display:flex;gap:10px;align-items:center;margin-bottom:20px;font-size:14px;overflow-wrap:anywhere}.chroma-file-wrapper{overflow:auto;font-size:12px}.chroma-file-wrapper pre{margin:0;padding:18px;line-height:1.8;min-width:max-content}html[data-theme=dark] .chroma-file-wrapper{filter:invert(.9) hue-rotate(180deg)}.file-wrapper{display:flex;overflow:auto;font-size:12px;line-height:1.8}.file-wrapper pre{margin:0;padding:18px}.line-numbers{padding:18px 12px;background:var(--subtle);font-family:monospace;text-align:right}.line-numbers a{display:block;color:var(--muted)}.commit-row{display:flex;gap:12px;padding:19px;align-items:center;border-bottom:1px solid var(--border)}.commit-row:last-child{border-bottom:0}.commit-row-content{flex:1;min-width:0}.commit-title{font-weight:550;display:block;color:var(--text);white-space:pre-wrap;font-size:13px;margin-bottom:5px}.commit-row-content .muted{font-size:11px}.subheading{display:flex;gap:10px;align-items:center;font-size:16px;margin:26px 0 12px}.ref-row{padding:16px;display:flex;justify-content:space-between;border-bottom:1px solid var(--border);gap:12px;align-items:center}.ref-row:last-child{border:0}.ref-row>a{display:flex;gap:10px;align-items:center}.ref-row p{margin:10px 0 0;font-size:12px}.ref-row .button{margin-left:7px}.commit{padding:22px;background:var(--subtle);border:1px solid var(--border);border-radius:7px;margin-bottom:25px;overflow-wrap:anywhere}.commit>pre{font-size:17px;white-space:pre-wrap}.commit-info{font-size:12px;color:var(--muted)}.commit-email{color:var(--muted)}.commit p{margin:5px 0 14px}.diff{border:1px solid var(--border);margin:20px 0;border-radius:7px;overflow:auto;padding:15px;font-size:12px}.diff pre{margin:14px -15px -15px;line-height:1.7}.diff pre p{background:var(--subtle);color:var(--muted);padding:3px 15px}.diff-add,.diff-del,.diff-noop{display:block;white-space:pre;padding:0 15px}.diff-add{background:#e6ffed;color:#204831}.diff-del{background:#ffeef0;color:#7b2530}.diff-stat{font-size:12px}.diff-type{font-weight:600;border:1px solid var(--border);border-radius:3px;padding:2px 5px;margin-right:8px}.error-page{text-align:center;padding:130px 25px}.error-page h1{margin-top:15px}.error-page p{color:var(--muted);margin-bottom:25px}#toast{position:fixed;bottom:25px;left:50%;transform:translate(-50%,20px);background:var(--text);color:var(--surface);border-radius:8px;padding:10px 22px;box-shadow:var(--shadow);font-size:12px;opacity:0;pointer-events:none;transition:.2s}#toast.visible{opacity:1;transform:translate(-50%,0)}[hidden]{display:none!important} +@media(min-width:1500px){.workspace-main{padding-left:60px;padding-right:60px}}@media(max-width:1150px){.workspace-layout{grid-template-columns:215px minmax(0,1fr)}.workspace-main{padding:25px 28px 0}.stat-note{display:none}.overview-cards{gap:9px}.overview-card{padding:13px;gap:9px}.overview-card .stat-word{font-size:10px}.repo-layout{grid-template-columns:minmax(0,1fr) 210px;gap:22px}.topbar{padding:0 24px}.sidebar{padding-left:12px;padding-right:12px}}@media(max-width:900px){.overview-cards{grid-template-columns:1fr 1fr}.overview-card:last-child{display:none}.hero-icon{display:none}.sidebar{padding-top:25px}.workspace-layout{grid-template-columns:190px minmax(0,1fr)}.workspace-main{padding:25px 24px 0}.topbar{gap:17px}.global-search{width:220px}.repo-layout{display:block}.repo-about{margin-top:30px;border-top:1px solid var(--border);padding-top:25px}.repo-heading-end{display:none}.repo-main{padding:25px}.repo-banner{padding-left:25px;padding-right:25px}.tip-key{display:none}.repo-card-meta{gap:8px;font-size:9px}.repo-card-content p{font-size:11px}.page-title h1{font-size:26px}.section-heading>.muted{display:none}.repository-card{padding:19px 16px}.repo-card-icon{display:none}.commit-date{display:none}}@media(max-width:650px){.topbar{height:65px;padding:0 18px;gap:13px}.brand{font-size:23px;gap:8px}.topbar>.top-separator,.top-right>.top-separator,.global-search{display:none}.top-right{gap:10px}.workspace-layout{display:block}.sidebar{display:none}.workspace-main{padding:23px 19px 0}.page-title{margin:28px 0 23px}.page-title h1{font-size:27px}.page-title p{font-size:11px;max-width:280px}.overview-card{min-height:80px;padding:12px}.overview-cards{margin-bottom:28px}.stat-icon{width:29px;height:29px}.stat-icon .icon{width:16px}.overview-card .stat-word{font-size:10px;max-width:100px}.overview-card div>span{font-size:9px}.sort-label select{max-width:115px;font-size:10px;padding-right:5px}.view-toggle{display:none}.repo-card-content p{max-width:90%}.repo-card-title h3{font-size:15px}.repo-card-meta{flex-wrap:wrap}.result-label{font-size:9px}.workspace-tip{font-size:9px}footer{gap:10px;font-size:9px}.footer-end{font-size:8px;gap:5px}.footer-end .status-dot{display:none}.repo-heading{font-size:16px;gap:8px}.repo-nav{gap:16px}.repo-nav>a{font-size:11px}.repo-main{padding:20px 16px}.repo-banner{padding:22px 16px 0}.toolbar-link{display:none}.repo-toolbar{gap:7px}.button{font-size:11px;padding:5px 10px}.clone-menu{width:290px}.latest-commit{padding:10px;font-size:10px}.latest-commit strong{display:none}.file-size{width:65px;font-size:9px}.file-kind{display:none}.file-row{padding:10px 12px}.markdown-body{padding:23px 19px;font-size:13px}.markdown-body h1{font-size:25px}.commit-row{padding:14px;gap:8px}.commit-row>.avatar{display:none}.ref-row .button{font-size:10px}.path-heading{font-size:12px}.repo-nav .icon{width:14px}.section-heading h1{font-size:24px}} + +/* Source-language shares use the same colors in repository cards and legends. */ +.language-dot{display:inline-block;width:10px;height:10px;border-radius:50%;flex-shrink:0;background:var(--muted)} +.language-bar{display:flex;height:9px;gap:2px;overflow:hidden;border-radius:6px;background:var(--subtle);margin:4px 0 15px} +.language-segment{display:block;flex-basis:0;min-width:0} +.language-legend{display:flex;flex-wrap:wrap;gap:9px 16px;list-style:none;margin:0;padding:0} +.language-legend li{display:flex;align-items:center;gap:7px;font-size:11px} +.language-legend strong{font-weight:550} +.repo-card-meta .language-dot{width:9px;height:9px}
A templates/404.html

@@ -0,0 +1,1 @@

+{{ define "404" }}<!doctype html><html lang="en">{{ template "head" . }}<body>{{ template "topbar" . }}<main class="error-page"><span class="eyebrow">404</span><h1>This page took a different branch.</h1><p>The repository, file, or reference you’re looking for could not be found.</p><a class="button primary" href="/">Back to repositories</a></main></body></html>{{ end }}
A templates/500.html

@@ -0,0 +1,1 @@

+{{ define "500" }}<!doctype html><html lang="en">{{ template "head" . }}<body>{{ template "topbar" . }}<main class="error-page"><span class="eyebrow">500</span><h1>Something went wrong.</h1><p>The repository could not be loaded. Check the server logs for details.</p><a class="button primary" href="/">Back to repositories</a></main></body></html>{{ end }}
A templates/commit.html

@@ -0,0 +1,105 @@

+{{ define "commit" }} +<!doctype html> +<html lang="en"> +{{ template "head" . }} + + <body> + {{ template "repoheader" . }} + + <main class="repo-main"> + <section class="commit"> + <pre> + {{- .commit.Message -}} + </pre> + <div class="commit-info"> + {{ .commit.Author.Name }} <a href="mailto:{{ .commit.Author.Email }}" class="commit-email">{{ .commit.Author.Email}}</a> + <div>{{ .commit.Author.When.Format "Mon, 02 Jan 2006 15:04:05 -0700" }}</div> + </div> + + <div> + <strong>commit</strong> + <p><a href="/{{ .name }}/commit/{{ .commit.This }}" class="commit-hash"> + {{ .commit.This }} + </a> + </p> + </div> + + {{ if .commit.Parent }} + <div> + <strong>parent</strong> + <p><a href="/{{ .name }}/commit/{{ .commit.Parent }}" class="commit-hash"> + {{ .commit.Parent }} + </a></p> + </div> + + {{ end }} + <div class="diff-stat"> + <div> + {{ .stat.FilesChanged }} files changed, + {{ .stat.Insertions }} insertions(+), + {{ .stat.Deletions }} deletions(-) + </div> + <div> + <br> + <strong>jump to</strong> + {{ range .diff }} + <ul> + <li><a href="#{{ .Name.New }}">{{ .Name.New }}</a></li> + </ul> + {{ end }} + </div> + </div> + </section> + <section> + {{ $repo := .name }} + {{ $this := .commit.This }} + {{ $parent := .commit.Parent }} + {{ range .diff }} + <div id="{{ .Name.New }}"> + <div class="diff"> + {{ if .IsNew }} + <span class="diff-type">A</span> + {{ end }} + {{ if .IsDelete }} + <span class="diff-type">D</span> + {{ end }} + {{ if not (or .IsNew .IsDelete) }} + <span class="diff-type">M</span> + {{ end }} + {{ if .Name.Old }} + <a href="/{{ $repo }}/blob/{{ $parent }}/{{ .Name.Old }}">{{ .Name.Old }}</a> + {{ if .Name.New }} + &#8594; + <a href="/{{ $repo }}/blob/{{ $this }}/{{ .Name.New }}">{{ .Name.New }}</a> + {{ end }} + {{ else }} + <a href="/{{ $repo }}/blob/{{ $this }}/{{ .Name.New }}">{{ .Name.New }}</a> + {{- end -}} + {{ if .IsBinary }} + <p>Not showing binary file.</p> + {{ else }} + <pre> + {{- range .TextFragments -}} + <p>{{- .Header -}}</p> + {{- range .Lines -}} + {{- if eq .Op.String "+" -}} + <span class="diff-add">{{ .String }}</span> + {{- end -}} + {{- if eq .Op.String "-" -}} + <span class="diff-del">{{ .String }}</span> + {{- end -}} + {{- if eq .Op.String " " -}} + <span class="diff-noop">{{ .String }}</span> + {{- end -}} + {{- end -}} + {{- end -}} + {{- end -}} + </pre> + </div> + </div> + {{ end }} + </section> + {{ template "footer" . }}</main> + </body> +</html> +{{ end }}
A templates/components.html

@@ -0,0 +1,29 @@

+{{ define "i-repo" }}<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 3h13v18H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2Zm0 14h13M6 6v7"/></svg>{{ end }} +{{ define "i-code" }}<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m8 6-6 6 6 6m8-12 6 6-6 6m-3-15-2 18"/></svg>{{ end }} +{{ define "i-branch" }}<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M6 3v12a4 4 0 0 0 4 4h6M6 8a3 3 0 1 0 0-6 3 3 0 0 0 0 6Zm12 14a3 3 0 1 0 0-6 3 3 0 0 0 0 6Zm0-14a3 3 0 1 0 0-6 3 3 0 0 0 0 6ZM6 13c0-5 12 0 12-5"/></svg>{{ end }} +{{ define "i-search" }}<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m21 21-5-5M18 10a8 8 0 1 1-16 0 8 8 0 0 1 16 0Z"/></svg>{{ end }} +{{ define "i-folder" }}<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M2 6h8l2 2h10v12H2ZM2 6V4h7l2 2"/></svg>{{ end }} +{{ define "i-file" }}<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M5 2h9l5 5v15H5Zm9 0v6h5"/></svg>{{ end }} +{{ define "i-clock" }}<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 7v5l3 2m7-2a10 10 0 1 1-20 0 10 10 0 0 1 20 0Z"/></svg>{{ end }} +{{ define "i-tag" }}<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 3h8l10 10-8 8L3 11ZM7 7h.01"/></svg>{{ end }} +{{ define "i-arrow" }}<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m9 5 7 7-7 7"/></svg>{{ end }} +{{ define "i-sun" }}<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 2v2m0 16v2M2 12h2m16 0h2M5 5l2 2m10 10 2 2M5 19l2-2M17 7l2-2M17 12a5 5 0 1 1-10 0 5 5 0 0 1 10 0Z"/></svg>{{ end }} +{{ define "i-download" }}<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 3v12m-5-5 5 5 5-5M4 16v5h16v-5"/></svg>{{ end }} +{{ define "i-copy" }}<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M8 8h13v13H8ZM16 8V2H2v14h6"/></svg>{{ end }} +{{ define "i-book" }}<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 5v16M2 3c4-1 7 0 10 2 3-2 6-3 10-2v15c-4-1-7 0-10 2-3-2-6-3-10-2Z"/></svg>{{ end }} +{{ define "i-grid" }}<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 3h7v7H3Zm11 0h7v7h-7ZM3 14h7v7H3Zm11 0h7v7h-7Z"/></svg>{{ end }} +{{ define "i-check" }}<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m5 12 4 4L19 6"/></svg>{{ end }} +{{ define "i-external" }}<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M14 3h7v7m0-7L10 14M10 3H3v18h18v-7"/></svg>{{ end }} +{{ define "i-terminal" }}<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m4 7 5 5-5 5m9 0h7"/></svg>{{ end }} +{{ define "topbar" }} +<header class="topbar"><a href="/" class="brand"><img class="brand-mark" src="{{ if .meta.FaviconHref }}{{ .meta.FaviconHref }}{{ else }}/static/favicon.svg{{ end }}" alt="" width="34" height="34"><span class="brand-name">{{ if .meta.Username }}{{ .meta.Username }}{{ else }}gitview{{ end }}</span></a><div class="top-right"><label class="global-search">{{ template "i-search" }}<input id="global-search" placeholder="Search repositories…" aria-label="Search repositories"><kbd>/</kbd></label><span class="top-separator"></span><button class="icon-button" id="theme-toggle" aria-label="Switch color theme" title="Switch color theme">{{ template "i-sun" }}</button></div></header> +{{ end }} +{{ define "footer" }}<footer><span class="footer-brand">{{ template "i-code" }} Gitview</span><span>Your code. Your home.</span><span class="footer-end">Built with Go <span class="status-dot"></span> Self-hosted</span></footer><div id="toast" role="status"></div>{{ end }} +{{ define "filetable" }} +<div class="file-table panel"> +{{ if .commits }}{{ with index .commits 0 }}<div class="latest-commit"><span class="avatar tiny">{{ printf "%.1s" .Commit.Author.Name }}</span><strong>{{ .Commit.Author.Name }}</strong><a class="commit-subject" href="/{{ $.name }}/commit/{{ .Commit.Hash.String }}">{{ .Commit.Message }}</a><a class="hash" href="/{{ $.name }}/commit/{{ .Commit.Hash.String }}">{{ slice .Commit.Hash.String 0 7 }}</a><span class="muted commit-date">{{ .Commit.Author.When.Format "Jan 2, 2006" }}</span></div>{{ end }}{{ end }} +{{ if .parent }}<a class="file-row" href="/{{ .name }}/tree/{{ .ref }}/{{ .dotdot }}">{{ template "i-folder" }}<strong>..</strong></a>{{ end }} +{{ range .files }}{{ if not .IsFile }}<a class="file-row" href="/{{ $.name }}/tree/{{ $.ref }}/{{ if $.parent }}{{ $.parent }}/{{ end }}{{ .Name }}">{{ template "i-folder" }}<span>{{ .Name }}</span><span class="file-kind">Directory</span><span class="file-size">{{ .Mode }}</span></a>{{ end }}{{ end }} +{{ range .files }}{{ if .IsFile }}<a class="file-row" href="/{{ $.name }}/blob/{{ $.ref }}/{{ if $.parent }}{{ $.parent }}/{{ end }}{{ .Name }}">{{ template "i-file" }}<span>{{ .Name }}</span><span class="file-kind">{{ .Mode }}</span><span class="file-size">{{ .Size }} B</span></a>{{ end }}{{ end }} +</div>{{ end }} +{{ define "toolbar" }}<div class="repo-toolbar"><details class="dropdown"><summary class="button">{{ template "i-branch" }}{{ .ref }}<span class="chevron">⌄</span></summary><div class="dropdown-menu"><div class="menu-title">Switch branches</div>{{ range .branches }}<a href="/{{ $.name }}/tree/{{ .Name.Short }}/">{{ template "i-branch" }}{{ .Name.Short }}</a>{{ else }}<a href="/{{ .name }}/refs/">View branches and tags</a>{{ end }}</div></details><a class="muted toolbar-link" href="/{{ .name }}/refs/">{{ template "i-branch" }} <strong>{{ len .branches }}</strong> branches</a><div class="toolbar-spacer"></div><a class="button" href="/{{ .name }}/log/{{ .ref }}">{{ template "i-clock" }}History</a><details class="dropdown clone-dropdown"><summary class="button primary">{{ template "i-code" }}Code<span class="chevron">⌄</span></summary><div class="dropdown-menu clone-menu"><div class="menu-title">{{ template "i-terminal" }} Clone repository</div><p>Get a local copy of this repository.</p><label class="field-label" for="clone-url">HTTPS</label><div class="copy-field"><input id="clone-url" readonly data-clone="/{{ .name }}" aria-label="Clone URL"><button class="icon-button" data-copy="clone-url" aria-label="Copy clone URL">{{ template "i-copy" }}</button></div><a href="/{{ .name }}/archive/{{ .ref }}.tar.gz">{{ template "i-download" }}Download tar.gz</a></div></details></div>{{ end }}
A templates/file.html

@@ -0,0 +1,1 @@

+{{ define "file" }}<!doctype html><html lang="en">{{ template "head" . }}<body>{{ template "repoheader" . }}<main class="repo-main"><div class="path-heading">{{ template "i-file" }}<a href="/{{ .name }}">{{ .displayname }}</a><span>/</span><strong>{{ .path }}</strong></div><section class="panel code-panel"><div class="panel-heading"><span>{{ .path }}</span><a class="button" href="?raw=true">Raw</a></div>{{ if .chroma }}<div class="chroma-file-wrapper">{{ .content }}</div>{{ else }}<div class="file-wrapper"><div class="line-numbers">{{ range .linecount }}<a id="L{{ . }}" href="#L{{ . }}">{{ . }}</a>{{ end }}</div><pre>{{ .content }}</pre></div>{{ end }}</section>{{ template "footer" . }}</main></body></html>{{ end }}
A templates/head.html

@@ -0,0 +1,1 @@

+{{ define "head" }}<head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><meta name="description" content="A thoughtful home for your Git repositories."><title>{{ if .displayname }}{{ .displayname }} · {{ end }}{{ if .meta.Title }}{{ .meta.Title }}{{ else }}Gitview{{ end }}</title><link rel="icon" href="{{ if .meta.FaviconHref }}{{ .meta.FaviconHref }}{{ else }}/static/favicon.svg{{ end }}"{{ if .meta.FaviconType }} type="{{ .meta.FaviconType }}"{{ end }}><link rel="stylesheet" href="/static/style.css"><script>try{document.documentElement.dataset.theme=localStorage.getItem('gitview-theme')||'light'}catch(e){}</script><script src="/static/app.js" defer></script>{{ if and .servername .gomod }}<meta name="go-import" content="{{ .servername }}/{{ .name }} git https://{{ .servername }}/{{ .name }}">{{ end }}</head>{{ end }}
A templates/index.html

@@ -0,0 +1,2 @@

+{{ define "index" }}<!doctype html><html lang="en">{{ template "head" . }}<body>{{ template "topbar" . }}<div class="workspace-layout"><aside class="sidebar"><a class="side-link selected" href="/">{{ template "i-repo" }}Repositories<span class="count">{{ .total }}</span></a><a class="side-link" href="#recent">{{ template "i-clock" }}Recent activity</a><div class="sidebar-divider"></div><div class="section-label side-section-title">REPOSITORIES<span>{{ .total }}</span></div>{{ range .info }}<a class="side-repo" href="/{{ .Name }}">{{ template "i-repo" }}{{ .DisplayName }}</a>{{ end }}<div class="sidebar-bottom">{{ template "i-terminal" }}Powered by Go & Git</div></aside> +<main class="workspace-main"><section class="repository-section" id="recent"><div class="section-heading"><h2>Repositories <span class="count">{{ .total }}</span></h2></div><div class="filter-bar"><label class="repo-search">{{ template "i-search" }}<input id="repo-search" placeholder="Find a repository…" aria-label="Find a repository"><kbd>/</kbd></label><label class="sort-label"><select id="repo-sort" aria-label="Sort repositories"><option value="updated" data-description="{{ if .pinned }}pinned, then last updated{{ else }}last updated{{ end }}">{{ if .pinned }}Pinned, then updated{{ else }}Last updated{{ end }}</option><option value="name">Name: A–Z</option></select></label><button class="icon-button view-toggle" id="view-toggle" aria-label="Toggle compact view" title="Toggle compact view">{{ template "i-grid" }}</button></div><div class="result-label"><span><strong id="result-count">{{ .total }}</strong> repositories</span><span>{{ template "i-clock" }} Sorted by <span id="sort-description">{{ if .pinned }}pinned, then last updated{{ else }}last updated{{ end }}</span></span></div><div id="repo-list" class="repository-list">{{ range $index, $repo := .info }}<article class="repository-card" data-repo="{{ .DisplayName }}" data-index="{{ $index }}"><div class="repo-card-icon">{{ template "i-repo" }}</div><div class="repo-card-content"><div class="repo-card-title"><h3><a href="/{{ .Name }}">{{ .DisplayName }}</a></h3><span class="badge">Public</span></div><p>{{ if .Desc }}{{ .Desc }}{{ else }}A Git repository.{{ end }}</p><div class="repo-card-meta">{{ if .Languages }}{{ with index .Languages 0 }}<span class="repo-language"><span class="language-dot" style="background-color:{{ .Color }}" aria-hidden="true"></span>{{ .Name }}</span>{{ end }}{{ else }}<span>{{ template "i-branch" }}Git repository</span>{{ end }}<span class="meta-dot">·</span><span>Updated {{ .Idle }}</span></div></div><a class="repo-open" href="/{{ .Name }}" aria-label="Open {{ .DisplayName }}">{{ template "i-arrow" }}</a></article>{{ else }}<div class="empty-state">{{ template "i-repo" }}<h3>No repositories yet</h3><p>Add Git repositories to your configured scan directory to explore them here.</p><code>git clone --bare &lt;repository-url&gt; repos/my-project.git</code></div>{{ end }}</div><div class="empty-state" id="no-results" hidden>{{ template "i-search" }}<h3>No repositories found</h3><p>Try another repository name or description.</p></div></section><div class="workspace-tip">{{ template "i-terminal" }}<span>A familiar workflow. <strong>Clone any repository</strong> and get right to work.</span><span class="tip-key">git clone &lt;url&gt;</span></div>{{ template "footer" . }}</main></div></body></html>{{ end }}
A templates/log.html

@@ -0,0 +1,1 @@

+{{ define "log" }}<!doctype html><html lang="en">{{ template "head" . }}<body>{{ template "repoheader" . }}<main class="repo-main"><div class="section-heading"><h1>Commit history</h1><span class="button">{{ template "i-branch" }}{{ .ref }}</span></div><div class="panel commit-list">{{ range .commits }}<div class="commit-row"><span class="avatar">{{ printf "%.1s" .Commit.Author.Name }}</span><div class="commit-row-content"><a class="commit-title" href="/{{ $.name }}/commit/{{ .Commit.Hash.String }}">{{ .Commit.Message }}</a><div class="muted"><strong>{{ .Commit.Author.Name }}</strong> committed on {{ .Commit.Author.When.Format "Jan 2, 2006" }}</div></div><a class="button hash" href="/{{ $.name }}/commit/{{ .Commit.Hash.String }}">{{ slice .Commit.Hash.String 0 7 }}</a></div>{{ end }}</div>{{ template "footer" . }}</main></body></html>{{ end }}
A templates/nav.html

@@ -0,0 +1,1 @@

+{{ define "nav" }}<nav class="repo-nav"><a class="{{ if not (or .log .isrefs .commit) }}active{{ end }}" href="/{{ .name }}">{{ template "i-code" }}Code</a><a class="{{ if or .log .commit }}active{{ end }}" href="/{{ .name }}/log/{{ if .ref }}{{ .ref }}{{ else }}HEAD{{ end }}">{{ template "i-clock" }}Commits</a><a class="{{ if .isrefs }}active{{ end }}" href="/{{ .name }}/refs/">{{ template "i-branch" }}Branches & tags</a></nav>{{ end }}
A templates/refs.html

@@ -0,0 +1,1 @@

+{{ define "refs" }}<!doctype html><html lang="en">{{ template "head" . }}<body>{{ template "repoheader" . }}<main class="repo-main"><h1>Branches & tags</h1><h2 class="subheading">{{ template "i-branch" }}Branches <span class="count">{{ len .branches }}</span></h2><div class="panel">{{ range .branches }}<div class="ref-row"><a href="/{{ $.name }}/tree/{{ .Name.Short }}/">{{ template "i-branch" }}<strong>{{ .Name.Short }}</strong></a><div><a class="button" href="/{{ $.name }}/log/{{ .Name.Short }}">History</a><a class="button" href="/{{ $.name }}/archive/{{ .Name.Short }}.tar.gz">{{ template "i-download" }}Source</a></div></div>{{ end }}</div><h2 class="subheading">{{ template "i-tag" }}Tags <span class="count">{{ len .tags }}</span></h2><div class="panel">{{ range .tags }}<div class="ref-row"><div><a href="/{{ $.name }}/tree/{{ .Name }}/"><strong>{{ .Name }}</strong></a>{{ if .Message }}<p>{{ .Message }}</p>{{ end }}</div><a class="button" href="/{{ $.name }}/archive/{{ .Name }}.tar.gz">{{ template "i-download" }}Source</a></div>{{ else }}<div class="empty-state"><h3>No tags yet</h3><p>Tags will appear here when they are added to the repository.</p></div>{{ end }}</div>{{ template "footer" . }}</main></body></html>{{ end }}
A templates/repo-header.html

@@ -0,0 +1,1 @@

+{{ define "repoheader" }}{{ template "topbar" . }}<div class="repo-banner"><div class="repo-heading">{{ template "i-repo" }}<a href="/">{{ if .meta.Username }}{{ .meta.Username }}{{ else }}{{ .meta.Title }}{{ end }}</a><span class="slash">/</span><a class="repo-name" href="/{{ .name }}">{{ .displayname }}</a><span class="badge">Public</span><span class="repo-heading-end">{{ template "i-check" }} Self-hosted repository</span></div>{{ template "nav" . }}</div>{{ end }}
A templates/repo.html

@@ -0,0 +1,1 @@

+{{ define "repo" }}<!doctype html><html lang="en">{{ template "head" . }}<body>{{ template "repoheader" . }}<main class="repo-main"><div class="repo-layout"><div class="repo-content">{{ template "toolbar" . }}{{ template "filetable" . }}{{ if .readme }}<section class="readme-panel panel"><div class="panel-heading">{{ template "i-book" }}README</div><article class="markdown-body">{{ .readme }}</article></section>{{ end }}</div><aside class="repo-about"><h2>About</h2><p>{{ if .desc }}{{ .desc }}{{ else }}A Git repository.{{ end }}</p><a href="#" data-scroll-readme>{{ template "i-book" }}Readme</a><a href="/{{ .name }}/log/{{ .ref }}">{{ template "i-clock" }}{{ .commitcount }} commits</a><a href="/{{ .name }}/refs/">{{ template "i-branch" }}{{ len .branches }} branches</a><div class="about-section"><h2>Tags <span class="count">{{ len .tags }}</span></h2>{{ range .tags }}<a href="/{{ $.name }}/tree/{{ .Name }}/">{{ template "i-tag" }}{{ .Name }}</a>{{ else }}<p class="muted">No tags published yet.</p>{{ end }}</div><section class="about-section repo-languages"><h2>Languages</h2>{{ if .languages }}<div class="language-bar" aria-hidden="true">{{ range .languages }}<span class="language-segment" style="flex-grow:{{ .Bytes }};background-color:{{ .Color }}" title="{{ .Name }} {{ printf "%.1f" .Percentage }}%"></span>{{ end }}</div><ul class="language-legend">{{ range .languages }}<li><span class="language-dot" style="background-color:{{ .Color }}" aria-hidden="true"></span><strong>{{ .Name }}</strong><span class="muted">{{ printf "%.1f" .Percentage }}%</span></li>{{ end }}</ul>{{ else }}<p class="muted">No source languages detected.</p>{{ end }}</section><div class="about-section"><h2>Clone & explore</h2><p class="muted">Take the code with you. Clone over HTTP or download an archive.</p><a href="/{{ .name }}/archive/{{ .ref }}.tar.gz">{{ template "i-download" }}Download source</a></div></aside></div>{{ template "footer" . }}</main></body></html>{{ end }}
A templates/tree.html

@@ -0,0 +1,1 @@

+{{ define "tree" }}<!doctype html><html lang="en">{{ template "head" . }}<body>{{ template "repoheader" . }}<main class="repo-main">{{ template "toolbar" . }}<div class="path-heading"><a href="/{{ .name }}">{{ .displayname }}</a><span>/</span><strong>{{ .parent }}</strong></div>{{ template "filetable" . }}{{ template "footer" . }}</main></body></html>{{ end }}