Migracje w źródłach projektu z użyciem Gradle’a

Gdy rozwijamy nasze projekty, z czasem powstają różne śmieci i zaszłości. W samym kodzie radzimy sobie z bałaganem robiąc od czasu do czasu refaktoring. W bazie danych robimy migracje. Co ze źródłami na naszym dysku?

Wyobraźmy sobie projekt z kilkoma podprojektami (modułami w terminologii Mavena):

.
├── big-data
├── model
└── persistence

Załóżmy, że w katalogu big-data trzymamy kod do wysyłania danych w formacie odpowiednim do analiz Big Data. Po jakimś czasie okazuje się, że powstała biblioteka, która załatwi to za nas i kod możemy wyrzucić. Kasuję katalog, popycham zmiany w Gicie do dzielonego repozytorium – i mamy big-data z głowy? Nie do końca.

Git śledzi zmiany w plikach, nie można w nim skasować katalogu – tylko konkretne pliki.

big-data
├── build
│ └── classes
└── src
└── main
└── java
└── com
└── example
└── Foo.java

To, co tak naprawdę zrobiłem, to poinformowałem Gita o skasowaniu big-data/src/main/java/com/example/Foo.java. Usunąłem też katalog big-data/build/, ale to już Gita nie obchodzi, bo wyłączyliśmy katalogi build ze śledzenia kontroli wersji. Moi koledzy z zespołu ściągając z serwera nowe commity też stracą z dysku źródła z katalogu big-data – ale sam katalog zostanie. “Winny” jest podkatalog build. Powstaje sytuacja, gdy ja jestem przekonany że posprzątałem śmieci – nie posiadam na dysku katalogu big-data, natomiast reszta zespołu zostaje z częścią owych śmieci.

Pokazuję tu bardzo uproszczony przypadek. W rzeczywistych projektach nieśledzone w Gicie pliki mogą zawierać bardzo różne rzeczy, np. definicje modułu w IDE (*.iml), zbudowane webaplikacje (*.war), pliki konfiguracyjne, pakiety NPM-a (node_modules/) – całe to tałatajstwo może przycinać IDE na czas indeksowania ich, zaśmiecać wyniki wyszukiwania i listy czynności do uruchomienia.

W dużych projektach może się okazać, że z czasem znaczna część podkatalogów na dysku będzie zawierać nie użyteczne podprojekty, ale podobne jak wyżej “trupy” pozostałe po kasowaniu i zmianach nazw. Takie rzeczy mogą rozpraszać i utrudniać orientację w projekcie, będą spowalniały, jeśli ktoś nawiguje po katalogach z konsoli i np. używa klawisza Tab do podpowiadania.

Można powiedzieć – żaden problem, przecież wystarczy te martwe katalogi ręcznie skasować. Nie uważam, że jest to takie proste. Zakładamy, że każdy w zespole doskonale orientuje się w strukturze projektu, śledzi najdrobniejsze zmiany, jest zawsze na czasie z ostatnimi zmianami, nawet jeśli wraca z dwutygodniowego urlopu. Chcemy chodzić od biurka do biurka i pokazywać każdemu, co wolno mu usunąć po ostatnim naszym sprzątaniu? Nie bardzo.

Druga wątpliwość: nie wystarczy “zrobić cleana”? Tu mam dwie obiekcje. Po pierwsze, filozofią dobrego narzędzia do budowania, jak Gradle, jest że użytkownik nigdy nie powinien wpisywać “clean”. Używamy sprytnego narzędzia opartego na przyrostowym działaniu, więc nie marnujmy całej zyskiwanej dzięki temu oszczędności czasu, wywalając inkrementalność do kosza. Po drugie, nie zawsze “clean” usunie wszystkie pliki nieśledzone przez Gita. Czasami projekty łamią zasady pisania dobrych skryptów Gradle’a i piszą poza katalog build/, a nie czyszczą tych miejsc w tasku “clean”. Czasami nasze IDE lubi coś sobie zapisać tu i tam. Czasem Java albo NodeJS wysypuje się, zapisując w katalogu uruchomienia plik logu. Jeden taki nieśledzony plik wystarczy, by Git zostawił na dysku katalog, z którego usunęliśmy wszystkie źródła.

Można skorzystać z polecenia git-clean do usunięcia wszystkiego, czego nie śledzi Git. Jest to skuteczne, ale jeszcze gorsze dla wydajności niż clean z poziomu Gradle’a: tracimy wszystkie przebudowane elementy. Dodatkowo osoba mało znający Gita może stracić swoją pracę, której nie dodała pod kontrolę wersji.

Nawet jeśli posprzątaliśmy ręcznie u każdego członka zespołu, wystarczy że ktoś cofnie się z wersją – robiąc git-bisect albo z jakiegoś innego powodu budując starszą wersję. Spowoduje to powrót problemu – pojawią się nieśledzone pliki, które nie znikną po powrocie z headem na aktualną gałąź master.

Moja propozycja: użyjmy narzędzia do budowania zgodnie z jego przeznaczeniem, do automatyzacji powtarzalnych czynności. Możemy napisać taska “migrującego” katalog projektu do aktualnej postaci. W build.gradle.kts na głównym poziomie dopisujemy:

val removeBigData = tasks.register<Delete>("removeBigData") {
    delete("big-data")
}

tasks["build"].dependsOn(removeBigData)

lub jeśli trzymamy się Groovy’ego:

task removeBigData(type: Delete) {
    delete 'big-data'
}

build.dependsOn(removeBigData)

Naszego taska migrującego podpinamy jako zależność często odpalanego taska – tutaj wybrałem “build”. W zależności jak używamy projektu, można zamiast “build” wybrać “assemble”.

Tym sposobem nawet jeśli ktoś nie orientuje się w strukturze projektu, odpalając zwykłe budowanie będzie miał do dyspozycji uporządkowany stan. Możemy bezpiecznie wracać do starszych commitów i nie zostaniemy ze śmieciami. Task migrujący możemy usunąć po pewnym czasie, gdy na pewno wszyscy w projekcie pozbyli się śmieci z dysku.