È stata pubblicata la versione 0.4 del progetto mergiraf, che sviluppa un driver per Git con l'implementazione della possibilità di fusione a tre vie. Mergiraf supporta la risoluzione di vari tipi di conflitti durante la fusione e può essere utilizzato per vari linguaggi di programmazione e formati di file. È possibile utilizzare mergiraf come chiamata separata per gestire i conflitti che si presentano durante l'uso di Git standard, oppure sostituire il gestore delle fusioni di Git per estendere le funzionalità di comandi come merge, revert, rebase e cherry-pick. Il codice è distribuito sotto licenza GPLv3. La nuova versione include il supporto per i linguaggi Python, TOML, Scala e Typescript, oltre a ottimizzazioni delle prestazioni.
Di seguito viene fornita una descrizione dettagliata dei problemi risolti con l'ausilio di mergiraf:
Il software è un chiaro esempio di un sistema estremamente complesso. I sistemi complessi hanno una caratteristica comune: sono COMPLESSI — e non ci si può aspettare che un comportamento complesso emerga spontaneamente, per caso. Al contrario, questi sistemi evolvono nel tempo, passo dopo passo, e ogni mutazione è accuratamente testata a ogni fase. Per raggiungere ciò è necessaria una struttura ben definita e gli strumenti appropriati. L'evoluzione di qualsiasi sistema complesso può essere visualizzata come un albero direzionato, dove la radice rappresenta un insieme vuoto di funzioni, e ogni nodo — ad eccezione della radice — è il risultato dell'applicazione di una mutazione al proprio genitore.
Nel contesto dei prodotti, ogni nodo è chiamato "versione", che rappresenta un insieme specifico di funzioni e anti-funzioni. Qualsiasi modifica di questo insieme è considerata una mutazione, formando un bordo nel nostro grafo aciclico diretto. Queste funzioni sono per loro natura astratte; non riflettono direttamente i modi in cui funzionano i sistemi fisici, ma piuttosto dimostrano come agenti razionali percepiscono l'utilità di questi sistemi. Per tradurre le idee nelle implementazioni esistenti nel mondo reale, è necessario rimboccarsi le maniche e immergersi in dettagli *abbastanza* a basso livello, in un linguaggio che consenta di esprimere e spiegare come funziona esattamente tutto. Nello sviluppo software, questi dettagli a basso livello sono solitamente rappresentati dal codice sorgente.
Per riportare gradualmente il codice sorgente in uno stato che mostri il comportamento richiesto e documentare come ci sono arrivati, i programmatori rappresentano il loro lavoro in termini di snapshot e changeset. Uno snapshot rappresenta uno stato specifico del prodotto con tutti i dettagli a basso livello, mentre un changeset indica il passaggio tra gli snapshot. Di solito, gli snapshot sono generati da singoli changeset ai loro genitori, quindi questi snapshot sono quasi sempre contrassegnati da ciò che fanno i changeset che li hanno creati, ed è per questo che questi termini sono frequentemente usati in modo intercambiabile.
A volte esistono snapshot ottenuti a seguito di più passaggi - fusione delle commit. Questi sono difficili da gestire e quindi vengono generalmente evitati. I moderni sistemi di controllo versione open source, come Git, offrono capacità piuttosto basilari per la gestione dei flussi di lavoro di sviluppo. Permettono agli sviluppatori di organizzare gli snapshot in grafi aciclici diretti, di annotarli con commenti e, se necessario, di cambiarne l'ordine.
Questa funzionalità consente agli sviluppatori di scrivere una storia semantica significante del progetto, fondamentale per il debug e per rispondere a domande come "Perché è stata introdotta questa dettaglio a basso livello (ad es., variabile)?", "Qual è stata circa la mia percentuale di contributo a questo progetto?", "Chi ha subito attacchi attraverso l'inserimento di un backdoor e quando?", "Quale cambiamento a basso livello ha rotto questa funzionalità (anche se apparentemente non avrebbe dovuto, abbiamo controllato tutto!)"
I sistemi di controllo versione completano questo concetto con i rami — un concetto a basso livello che significa semplicemente un frammento continuo della storia a basso livello del progetto, semanticamente significativo per lo sviluppatore. I rami vengono generalmente utilizzati per la realizzazione specifica delle funzionalità, e a volte vengono creati più rami per diversi candidati all'implementazione della stessa funzionalità. Utilizzando flussi di lavoro con branching (che sono in effetti la norma e lo standard di sviluppo, utilizzati ovunque), ogni singolo sviluppatore può gestire efficacemente molti rami conflittuali del progetto, ognuno dei quali si differenzia per grado di prontezza o qualità. Questo consente agli sviluppatori di combinare i risultati dei propri sforzi e di quelli degli altri senza dover riscrivere tutto manualmente ogni volta.
Di solito viene creato un ramo principale, che rappresenta il prodotto 'ufficiale', da cui si diramano i rami secondari per ogni funzionalità, che vengono regolarmente (idealmente dopo ogni commit) sincronizzati con il ramo principale, permettendo così agli sviluppatori di lavorare con la versione più aggiornata del prodotto e, allo stesso tempo, di integrare le funzionalità che stanno sviluppando, rilevando i problemi causati dalle azioni degli altri sviluppatori il prima possibile.
Quando si tenta di combinare le funzionalità di vari snapshot (che consiste semplicemente nel trovare un antenato comune e applicare i changeset che li generano uno sopra l'altro, questa operazione è chiamata rebase, mentre il merge è quasi come un rebase, semplicemente struttura il grafo dei commit in modo diverso, rendendolo poco manovrabile, per cui si cerca di evitare i merge in favore dei rebase), si presentano delle problematiche. I moderni sistemi di controllo versione (VCS) utilizzano algoritmi interni per la fusione delle modifiche, che semplicemente suddividono i file in righe separate, trattando ogni riga come un simbolo e i file come le loro sequenze, e poi applicano algoritmi per unirli, che hanno origine dalla bioinformatica.
Purtroppo, questa rappresentazione riga per riga del codice sorgente non ha nulla a che fare con il suo contenuto. Il suo unico pregio è che è semplice e universale. La discrepanza porta a conflitti, risultando in una continua fonte di mal di testa per gli sviluppatori. Risolvere i conflitti richiede allo sviluppatore di esaminare attentamente entrambe le versioni del codice, non solo le sezioni contrassegnate dall'algoritmo di confronto riga per riga come "modificate" o "in conflitto", ma forse anche l'intero progetto.
Lo sviluppatore deve comprendere le modifiche, scrivere manualmente il codice unito e risolvere eventuali discrepanze. I problemi aumentano quando lo strumento riga per riga identifica erroneamente le modifiche, il che accade spesso in caso di grandi cambiamenti, inclusi quelli banali, come la riformattazione del codice. Se le modifiche successive non possono essere applicate al codice unito manualmente, la situazione diventa un incubo totale. Nonostante i casi spaventosi, nella maggior parte dei casi l'algoritmo riga per riga funziona, specialmente se gli sviluppatori si adoperano attivamente per non generargli problemi. Uno dei modi per minimizzare tali problemi è l'obbligo di elaborare i sorgenti con strumenti di canonicalizzazione, come black.
Naturalmente, la soluzione corretta per i casi spaventosi (e non solo per loro, l'algoritmo riga per riga è un'euristica, può trivialmente portare a codice non funzionante, ad esempio se uno sviluppatore rinomina una variabile mentre un altro scrive nel frattempo un pezzo di codice nuovo che utilizza quella variabile, non ci sarà conflitto di fusione/ribase, ma il risultato diventerà non funzionante) è l'uso di un modello interno corretto.
Sebbene la ricerca in questo campo sia in corso da circa 30 anni e abbia portato alla creazione di diversi prodotti commerciali proprietari, fino a poco tempo fa queste ricerche non erano state trasformate in prodotti pratici applicabili a codice aperto. La maggior parte delle soluzioni di software libero ha iniziato a svilupparsi all'inizio degli anni 2010, ed era focalizzata principalmente sul linguaggio Java.
La realizzazione free più notevole di quel periodo, GumTree, è stata creata da un ricercatore con una formazione accademica, è scritta in Java, ha una propria rappresentazione interna astratta, antecedente a treesitter, e ha backend sia basati su treesitter che su altri strumenti per il parsing del codice sorgente in rappresentazioni astratte. Questo sistema è capace solo di generare (sotto forma di log di eventi testuali, esiste anche un'API che può essere richiamata facilmente da qualsiasi linguaggio di programmazione che ha binding a Java) e di visualizzare modifiche. Tuttavia, per unire modifiche, così come per visualizzare i file diff generati, non è applicabile out-of-the-box (sì, è probabile che il caricamento dei diff possa essere implementato tramite l'API).
La realizzazione più giovane e più praticabile di difftastic è scritta in Rust, è basata su treesitter e si concentra sulla generazione di diff evidenziati in console. Questo sistema è anch'esso dedicato alla visualizzazione dei diff e non ha come obiettivo l'unione delle modifiche o l'applicazione di patch.
Recentemente è emerso e si sta sviluppando attivamente il progetto mergiraf. Questo strumento, scritto in Rust (occupando 21 MiB!), è anch'esso basato su treesitter, che è già diventato uno standard per i parser di grammatiche libere da contesto negli strumenti di sviluppo, come LLVM lo è per l'ottimizzazione delle rappresentazioni delle istruzioni a basso livello. A differenza dei concorrenti, mergiraf fornisce funzionalità non per la generazione di diff, ma per la risoluzione automatica dei conflitti di unione. Sotto il cofano, mergiraf utilizza l'implementazione dell'algoritmo usato in GumTree per la generazione delle patch, e l'implementazione dell'algoritmo usato in spork, adattate per le strutture treesitter.
La serializzazione delle patch in file che possono essere applicati in seguito, purtroppo, non è stata implementata (ma è molto probabile che possa essere realizzata tramite il parsing dei log degli eventi generati da GumTree). Un altro modo promettente per applicare le differenze potrebbe essere il ricorso non a patch, ma alla funzionalità di refactoring dei server LSP, che può aiutare nel rilevamento dei conflitti a livello di progetto. La visualizzazione è supportata solo per i conflitti.
Esempio di lavoro: antenato comune «base.py» (rientri con tabulazioni, riga extra all'inizio) foo = 1 def main(): print(foo + 2 + 3) «a.py» (rientri ancora con tabulazioni, 2 righe extra all'inizio invece di una, per la stampa di debug è stata utilizzata la libreria icecream, aggiunta la classe «baz»: from icecream import ic foo = 1 def main(): ic(foo + 2 + 3) class baz: def __init__(self): «»»baz»»» «b.py» (la variabile «foo» è stata rinominata in «bar», trattato con «black» dopo le modifiche, il risultato sono rientri con spazi e righe extra eliminate): bar = 1 def main(): print(bar + 2 + 3) Chiamata .\/mergiraf merge .\/base.py .\/a.py .\/b.py -x a.py -y b.py -s base.py -o .\/res.py dà il seguente risultato from icecream import ic bar = 1 def main(): ic(bar + 2 + 3) class baz: def __init__(self): «»»baz»»» (per la stampa di debug è stata utilizzata la libreria «icecream», la variabile «foo» è stata rinominata in «bar», trattato con «black» dopo le modifiche, il risultato sono rientri con spazi e righe extra eliminate, mescolanza di tabulazioni e spazi per il rientro, ma visibilmente accettabile).
Qui è evidente il difetto dello strumento. Lo stile del documento è solitamente configurato nei file «.editorconfig», e le modifiche globali allo stile, come il passaggio da tabulazioni a spazi e l'adozione dello stile di black, come fatto in «b.py», sono generalmente accompagnati da modifiche in «.editorconfig». Pertanto, per applicare in modo più corretto tali modifiche, lo strumento deve avere una concezione per uno stile globale «predefinito» e sapersi attingere alle impostazioni da «.editorconfig».
Fonte: opennet.ru
