Dev.to AI 🤖 Ai 👁 0 📖 4 min read

AI coding agent के बदलाव git checkout से undo क्यों नहीं होते?

जब कोई AI एजेंट आपके प्रोजेक्ट की फ़ाइलें एडिट करता है और कुछ तोड़ देता है, तो पहली आदत होती है git checkout -- . या git stash चलाना। लेकिन ये दोनों उस सवाल का जवाब देते हैं जो आप असल में पूछ ही नहीं रहे। Git आपको आख़िरी

जब कोई AI एजेंट आपके प्रोजेक्ट की फ़ाइलें एडिट करता है और कुछ तोड़ देता है, तो पहली आदत होती है git checkout -- . या git stash चलाना। लेकिन ये दोनों उस सवाल का जवाब देते हैं जो आप असल में पूछ ही नहीं रहे। Git आपको आख़िरी commit पर वापस ले जा सकता है। आपको चाहिए वह फ़ोल्डर, जैसा वह एजेंट के turn से ठीक पहले था: आपका अपना uncommitted काम और git जिन फ़ाइलों को ignore करता है वे मौजूद रहें, और एजेंट की बनाई फ़ाइलें न रहें।

osFoundry डेस्कटॉप ऐप इसे Rust में लिखे एक छोटे content-addressed store, checkpoint_store.rs, से हल करता है। एजेंट का write किसी फ़ाइल तक पहुँचने से पहले, उस फ़ाइल के मौजूदा bytes <project>/.osfoundry/checkpoints/blobs/<sha256> में सेव हो जाते हैं, और /rewind उन्हें वापस लिख देता है। नीचे वही डिज़ाइन लगभग 90 लाइन की स्टैंडर्ड-लाइब्रेरी Python में है, एक असली जैसे turn पर चार git तरीकों से तुलना की गई है, और 4,686 फ़ाइलों वाले एक असली TypeScript source tree की कॉपी पर समय मापा गया है।

एक turn, उसे undo करने के पाँच तरीके

टेस्ट प्रोजेक्ट एक git repository में रखी छोटी-सी साइट है: index.html, styles.css (212 लाइनें), app.js, और एक .env जिसे .gitignore बाहर रखता है। आख़िरी commit के बाद यूज़र ने index.html में पेज का title बदला और एक untracked notes.md लिखना शुरू किया। वापस इसी स्थिति में पहुँचना है।

फिर एजेंट का turn: वह styles.css को छोटा करके 41 लाइनों में दोबारा लिखता है, index.html को एडिट करके नई theme.css लोड करवाता है, app.js दोबारा लिखता है, .env में API_URL को localhost पर पॉइंट करता है, और theme.css बनाता है। हर undo तरीका एक नई कॉपी पर चलाया गया, और एक स्क्रिप्ट ने हर फ़ाइल की turn से पहले वाली स्थिति से byte-by-byte तुलना की।

checkpoint restore                 matches the state before the turn: True   differs: []
git checkout -- .                  matches the state before the turn: False  differs: ['.env', 'index.html', 'theme.css']
git stash -u                       matches the state before the turn: False  differs: ['.env', 'index.html', 'notes.md']
git checkout + git clean -fd       matches the state before the turn: False  differs: ['.env', 'index.html', 'notes.md']
git stash create before the turn   matches the state before the turn: False  differs: ['.env', 'theme.css']
तरीका क्या गड़बड़ होती है
git checkout -- . यूज़र का title वाला बदलाव एजेंट के बदलावों के साथ चला जाता है, theme.css बनी रहती है, .env में एजेंट वाली value रहती है
git stash -u एजेंट के बदलाव, यूज़र का title वाला बदलाव और notes.md सब एक ही stash में चले जाते हैं, और .env में एजेंट वाली value रहती है
git checkout + git clean -fd theme.css हट जाती है, लेकिन notes.md भी हट जाती है, title वाला बदलाव चला जाता है, और .env में एजेंट वाली value रहती है
turn से पहले git stash create सारी tracked फ़ाइलें वापस आ जाती हैं, लेकिन theme.css बनी रहती है और .env कभी snapshot में आती ही नहीं
checkpoint restore turn से पहले वाली स्थिति से मेल खाता है

git के ये नतीजे इसलिए हैं क्योंकि git ऐसे ही काम करता है। वह आख़िरी commit को, या tracked और staged फ़ाइलों के snapshot को restore करता है, और जब तक कुछ commit नहीं हुआ, दोनों ही आपके काम को एजेंट के काम के साथ मिला देते हैं। Ignored फ़ाइलें git को तब तक दिखती ही नहीं जब तक आप -a न जोड़ें, और checkout ऐसी फ़ाइल कभी डिलीट नहीं करता जो snapshot में थी ही नहीं। आख़िरी पंक्ति git के लिए सबसे fair setup है, यानी हर turn से पहले लिया गया snapshot, और फिर भी वह पाँच में से दो फ़ाइलें चूक जाता है। इसका एक side effect भी है: हर turn पर git add -A आपका सारा काम असली index में stage कर देता है।

Store

Checkpoint हर turn की एक लिस्ट है: एजेंट ने जो भी फ़ाइल लिखी, उसके लिए या तो उसके पुराने content का hash, या यह नोट कि वह फ़ाइल मौजूद नहीं थी। Bytes हर अलग content के लिए एक बार blobs/ में जाते हैं।

import hashlib, json, os, time
from pathlib import Path

MAX_BLOB = 8 * 1024 * 1024

class Checkpoints:
    def __init__(self, root):
        self.root = Path(root).resolve()
        self.dir = self.root / ".osfoundry" / "checkpoints"
        self.blobs = self.dir / "blobs"
        self.index_file = self.dir / "index.json"
        try:
            self.index = json.loads(self.index_file.read_text())
        except (OSError, ValueError):
            self.index = []

    def _save(self):
        self.dir.mkdir(parents=True, exist_ok=True)
        tmp = self.dir / "index.json.tmp"
        tmp.write_text(json.dumps(self.index))
        os.replace(tmp, self.index_file)  # a half-written index loses everything

    def capture(self, turn, label, path):
        path = str(Path(path).resolve()) if Path(path).exists() else str(Path(path).absolute())
        if not self._contained(path):
            raise ValueError("outside the workspace: " + path)
        cp = next((c for c in self.index if c["turn"] == turn), None)
        if cp and any(f["path"] == path for f in cp["files"]):
            return                      # first write in a turn wins
        try:
            size = os.path.getsize(path)
        except FileNotFoundError:
            pre = {"kind": "absent"}
        else:
            if size > MAX_BLOB:
                pre = {"kind": "skipped", "reason": f"{size >> 20} MB over the limit"}
            else:
                data = Path(path).read_bytes()
                sha = hashlib.sha256(data).hexdigest()
                blob = self.blobs / sha
                if not blob.exists():   # identical bytes are stored once
                    self.blobs.mkdir(parents=True, exist_ok=True)
                    blob.write_bytes(data)
                pre = {"kind": "content", "sha256": sha, "bytes": size}
        entry = {"path": path, "pre": pre}
        if cp:
            cp["files"].append(entry)
        else:
            self.index.append({"turn": turn, "label": label[:120],
                               "at": time.time_ns(), "files": [entry]})
        self._save()

    def write(self, turn, label, path, data):
        """What an agent's write tool would call."""
        self.capture(turn, label, path)
        Path(path).parent.mkdir(parents=True, exist_ok=True)
        Path(path).write_bytes(data)

Restore सही है या नहीं, यह तीन बातों पर टिका है, और हर एक Rust मॉड्यूल में भी एक नियम है।

एक turn में किसी path का पहला capture ही मान्य होता है। एजेंट अक्सर एक ही turn में एक ही फ़ाइल कई बार लिखता है। Rewind का मतलब है फ़ाइल को उस हालत में लौटाना जो turn शुरू होते समय थी, न कि एजेंट के अपने दो writes के बीच की किसी हालत में, इसलिए उसी turn में उसी path के बाद वाले captures ignore कर दिए जाते हैं।

जो फ़ाइल मौजूद नहीं थी, उसे absent के रूप में रिकॉर्ड किया जाता है। यही एक लाइन restore को theme.css डिलीट करने देती है। Git में "यह फ़ाइल मौजूद नहीं होनी चाहिए" के लिए कुछ नहीं है, सिवाय git clean के, जो वे फ़ाइलें भी डिलीट कर देता है जिन्हें आप रखना चाहते थे।

8 MB से बड़ी फ़ाइलें छोड़ी नहीं जातीं, बल्कि कारण के साथ skipped के रूप में रिकॉर्ड होती हैं। फिर restore बता देता है कि वह उन्हें वापस नहीं ला सका, बजाय इसके कि चुपचाप पुरानी और नई फ़ाइलों का मिश्रण छोड़कर ख़त्म हो जाए।

Restore, सबसे पुराने से शुरू करके

    def restore(self, turn):
        target = next(c["at"] for c in self.index if c["turn"] == turn)
        affected = sorted((c for c in self.index if c["at"] >= target),
                          key=lambda c: c["at"])          # oldest first
        done, report = set(), {"restored": [], "deleted": [], "failed": []}
        for cp in affected:
            for f in cp["files"]:
                path, pre = f["path"], f["pre"]
                if path in done:
                    continue
                done.add(path)
                if not self._contained(path):            # re-check, the index is just a file
                    report["failed"].append((path, "outside the workspace"))
                elif pre["kind"] == "skipped":
                    report["failed"].append((path, pre["reason"]))
                elif pre["kind"] == "absent":
                    Path(path).unlink(missing_ok=True)
                    report["deleted"].append(path)
                else:
                    Path(path).parent.mkdir(parents=True, exist_ok=True)
                    Path(path).write_bytes((self.blobs / pre["sha256"]).read_bytes())
                    report["restored"].append(path)
        self.index = [c for c in self.index if c["at"] < target]
        self._save()
        return report

    def _contained(self, path):
        p = Path(path)
        if ".." in p.parts:
            return False
        p = p if p.is_absolute() else self.root / p
        while not p.exists():          # deepest existing ancestor, symlinks resolved
            p = p.parent
        return p.resolve().is_relative_to(self.root)

अगर turn 1 के बाद turn 2 और 3 आए हों, तो turn 1 पर rewind करने से तीनों undo होने चाहिए। सिर्फ़ turn 1 restore करने पर turn 2 की फ़ाइलें turn 1 से पहले की फ़ाइलों के साथ पड़ी रह जाएँगी, एक ऐसा combination जो कभी मौजूद ही नहीं था। क्रम भी मायने रखता है। जिस फ़ाइल को कई turns में छुआ गया हो, उसके सही bytes सबसे पुराना pre-image हैं, यानी वह जो turn 1 शुरू होते समय capture हुआ था। सबसे नए से पीछे की ओर चलने पर आप turn 2 के pre-image पर पहुँचते हैं, जो दरअसल turn 1 की बनाई हुई स्थिति है। Rust source में ठीक इसी बात पर एक comment है: पहले इसे newest-first लिखा गया था, और टेस्ट rewinding_a_turn_also_undoes_later_turns ने इसे पकड़ा।

Restore के समय path check दोबारा चलता है, क्योंकि index.json उसी फ़ोल्डर के अंदर रखी एक सादी फ़ाइल है जिसका वह वर्णन करती है। अगर उस पर भरोसा कर लिया जाए, तो जो भी व्यक्ति या प्रोग्राम उसे एडिट कर सकता है, वह restore से डिस्क पर कहीं भी लिखवा सकता है। इसके बाद rewind किए गए turns index से हटा दिए जाते हैं, ताकि उसी पॉइंट पर दूसरा rewind पहले से वापस आ चुकी फ़ाइलों पर "सफल" न हो सके।

असली फ़ोल्डर पर इसकी लागत

Tree एक असली React और TypeScript ऐप के src फ़ोल्डर की कॉपी है: 4,686 फ़ाइलें, 121.4 MB, कोई फ़ाइल 8 MB से बड़ी नहीं। हर turn ने random फ़ाइलें चुनीं, उन्हें capture किया और हर एक में एक लाइन जोड़ी, फिर restore चला और हर फ़ाइल की original से तुलना की गई। सारे restores मेल खाए। Apple M1, 8 GB, macOS 15, APFS SSD, Python 3.9.6, git 2.39.5।

Turn Capture + write Store का साइज़ Restore
4 फ़ाइलें + 1 नई 3.5 ms 0.1 MB से कम 1.2 ms
50 फ़ाइलें + 1 नई 62.4 ms 0.9 MB 8.2 ms
500 फ़ाइलें + 1 नई 1,035 ms 11.7 MB 227 ms
सभी 4,686 फ़ाइलें 30.5 s 122.7 MB 1.3 s

तुलना के लिए, ऊपर वाले scenario का per-turn git snapshot (git add -A फिर git stash create) इस tree पर कुछ न बदलने पर 61 से 68 ms और चार फ़ाइलें एडिट होने पर 50 से 67 ms लेता था। उसे हर बार सभी 4,686 फ़ाइलें देखनी पड़ती हैं। सारी फ़ाइलें दोबारा लिखने वाले run के ठीक बाद अगले snapshot ने 1,241 ms लिए, क्योंकि git हर उस फ़ाइल को दोबारा hash करता है जिसका timestamp बदला हो। Checkpoint सिर्फ़ उन्हीं फ़ाइलों को देखता है जो लिखी जा रही हैं, इसलिए एक सामान्य turn में प्रोजेक्ट चाहे जितना बड़ा हो, कुछ ही milliseconds लगते हैं।

आख़िरी पंक्ति मौजूदा कोड की एक समस्या दिखाती है। उस turn की पहली 100 फ़ाइलों पर औसत capture 1.29 ms का था और आख़िरी 100 पर 9.54 ms का। हर capture पूरी index.json दोबारा लिखता है, जो 1.3 MB तक पहुँच गई थी, इसलिए हर write की लागत उससे पहले हुए writes की संख्या के साथ बढ़ती जाती है। _save की जगह हर capture पर एक JSON लाइन append करने से उन्हीं 4,686 फ़ाइलों पर यह ठीक हो गया: कुल 2.5 s, शुरुआत में 0.41 ms प्रति capture और आख़िर में 0.61 ms। जो एजेंट हर turn में कुछ ही फ़ाइलें एडिट करता है, उसके लिए सादा वर्ज़न काफ़ी है। अगर एजेंट से formatter या पूरे codebase में rename चलवाना है, तो append-only log इस्तेमाल करें।

Content addressing से क्या बचता है, और क्या नहीं

Blobs का नाम उनके SHA-256 से रखने पर एक जैसे bytes deduplicate हो जाते हैं, बस इतना ही। तीन measurements दिखाते हैं कि इसका असर कितना है।

पूरे tree में 4,686 फ़ाइलों के 4,677 अलग-अलग contents थे, इसलिए पूरे tree वाले turn ने 4,677 blobs स्टोर किए। असली source tree में duplicate फ़ाइलें कम ही होती हैं।

एक 2 MB फ़ाइल दस turns में दोबारा लिखी गई, और एजेंट ने हर बार एक लाइन जोड़ी: दस pre-images, सब अलग, 10 blobs और 20.0 MB। वही फ़ाइल, लेकिन एजेंट हर turn पर वही bytes लिखता है, जैसा तब होता है जब retry बिल्कुल वही output देता है: 2 blobs और 4.0 MB, यानी original और वह वर्ज़न जिससे बाद के सभी turns शुरू हुए। दोनों ही मामलों में पहले turn पर rewind करने से original byte-by-byte वापस मिल गया।

Blobs raw स्टोर होते हैं। पूरे tree वाले turn के 121.4 MB blobs zlib level 6 के साथ 33.7 MB होते, और उसी tree के एक commit के बाद git का अपना object store 41.0 MB था। अगर store लंबे समय तक रखना है तो compression जोड़ना ठीक रहेगा, क्योंकि source code लगभग 3.6 से 1 के अनुपात में compress होता है। Git की pack files की तरह वर्ज़न के बीच deltas रखने से एक लाइन जोड़ने वाले केस में और बचत होती, लेकिन उसमें pre-image store की ज़रूरत से कहीं ज़्यादा कोड लगता है।

इस वर्ज़न की सीमाएँ

यह सिर्फ़ वही writes देखता है जो write() से होकर जाते हैं। अगर आपका एजेंट shell commands भी चला सकता है, तो rm या sed -i सीधे बच निकलते हैं। osFoundry ऐप एजेंट के सारे file writes एक ही function से होकर भेजता है, और capture वहीं होता है।

Restore फ़ाइल में अभी जो भी हो, pre-image लिख देता है। अगर एजेंट के बाद आपने कोई फ़ाइल हाथ से एडिट की, तो उस turn को rewind करने पर आपका बदलाव भी overwrite हो जाएगा। एक सख़्त वर्ज़न एजेंट के लिखे content का hash रिकॉर्ड करता है, और restore से पहले जाँचता है कि फ़ाइल में अब भी वही hash है या नहीं। अगर नहीं, तो वह पहले मौजूदा bytes को एक और blob के रूप में सेव करता है, ताकि restore को भी undo किया जा सके।

Store तब तक बढ़ता रहता है जब तक turns rewind न हों। एक prune जो उन blobs को डिलीट करे जिन्हें कोई checkpoint refer नहीं करता, और कितने turns रखने हैं इसकी एक सीमा, इतना काफ़ी है।

ऊपर के दो class blocks ही पूरा store हैं, स्टैंडर्ड-लाइब्रेरी Python 3.9, कुछ भी install करने की ज़रूरत नहीं। osFoundry डेस्कटॉप ऐप Rust वर्ज़न को उन chats में /rewind के रूप में देता है जो किसी local folder में काम करती हैं, और यह osFoundry download page से मुफ़्त मिलता है।

📰 Read the original article on Dev.to AI

Originally published by Dev.to AI. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.