diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d53f013..63161d3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -35,6 +35,8 @@ jobs: node-version: ${{ matrix.node }} cache: npm - run: npm ci + - name: Ensure git-lfs is installed + run: git lfs version || (sudo apt-get update && sudo apt-get install -y git-lfs) - run: npm run lint - run: npm run typecheck - name: Wait for the S3 server diff --git a/LEARNINGS.md b/LEARNINGS.md index f6b752c..f4813d0 100644 --- a/LEARNINGS.md +++ b/LEARNINGS.md @@ -11,3 +11,6 @@ Claude Code bulut oturumlarında `~/.claude/CLAUDE.md` bulunmaz. Yalnızca repod ## 2026-10-14 — AWS SDK v3 varsayılan olarak CRC32 sağlama toplamı ekler Üçüncü parti S3 servisleri bu başlıkları reddedebildiği için istemci `requestChecksumCalculation` ve `responseChecksumValidation` için `WHEN_REQUIRED` ile oluşturulur (K-035); `DeleteObjects` gibi zorunlu kılan işlemler yine sağlama toplamı gönderir. + +## 2026-10-14 — LFS testleri için sunucu gerekmez: standalone file transfer +`git-lfs` yerleşik `lfs-standalone-file` aktarımıyla `lfs.url=file://` bir LFS sunucusu gibi kullanılabilir (`lfs.standalonetransferagent=lfs-standalone-file`). Dizin var olan bir git dizini (ör. `git init --bare`) olmalıdır; aksi halde "chdir" veya "not a git repository" hatası gelir. Nesneler `/lfs/objects` altına yazılır. Bare mirror'da `git lfs fetch --all` LFS'siz repoda da hata vermez ve boş bir `lfs/` bırakır; "LFS var mı" kontrolü dizinin değil dosyanın varlığına bakmalıdır. diff --git a/README.md b/README.md index 4dbfc40..279e129 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ A Node.js/TypeScript CLI (`github-backup`) that backs up every repository you can reach through the GitHub CLI (`gh`): your personal repositories and those of all organizations you belong to, with wikis and Git LFS objects. Backups go to a local directory and/or any S3-compatible bucket (AWS S3, Cloudflare R2, Backblaze B2, MinIO) as git bundle snapshots, and a new snapshot is created only when refs change. -Status: under development. `list` and `backup` to local and S3 targets work; Git LFS is not implemented yet. See the plan (Turkish) in [plans/README.md](plans/README.md). +Status: under development. `list` and `backup` to local and S3 targets work, including wikis and Git LFS objects (`.lfs.tar` next to each bundle; needs `git-lfs`, or pass `--no-lfs`). See the plan (Turkish) in [plans/README.md](plans/README.md). ## Requirements diff --git a/developer-todos.md b/developer-todos.md index 1d6f360..bf6b7c9 100644 --- a/developer-todos.md +++ b/developer-todos.md @@ -28,7 +28,7 @@ Sıradaki fazı engelleyen işler. ### Faz 4 — Git LFS -- [ ] 💻 LFS kullanan bir repo varsa yedekleyip README'deki adımlarla geri yükle. +- [ ] 💻 LFS kullanan gerçek bir GitHub reposunu yedekle (`node dist/cli.js backup --local ~/git-backup-test --include '/'`), `.lfs.tar` oluştuğunu gör ve [faz-04-lfs.md](plans/faz-04-lfs.md) **Devam notları**'ndaki adımlarla bir LFS dosyasını geri yükleyip sha256'sını orijinalle karşılaştır: gerçek `gh` oturumu ve gerçek LFS sunucusu gerekir, bulutta yok. ### Faz 5 — Dokümantasyon ve son kontrol diff --git a/package-lock.json b/package-lock.json index 696a892..0a12cdd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,6 +11,7 @@ "@aws-sdk/client-s3": "^3.1152.0", "@aws-sdk/lib-storage": "^3.1152.0", "commander": "^15.0.0", + "tar": "^7.5.22", "zod": "^4.6.7" }, "bin": { @@ -960,6 +961,18 @@ "node": ">=18" } }, + "node_modules/@isaacs/fs-minipass": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz", + "integrity": "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==", + "license": "ISC", + "dependencies": { + "minipass": "^7.0.4" + }, + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/@jridgewell/resolve-uri": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", @@ -1827,6 +1840,15 @@ "node": ">=18" } }, + "node_modules/chownr": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz", + "integrity": "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, "node_modules/commander": { "version": "15.0.0", "resolved": "https://registry.npmjs.org/commander/-/commander-15.0.0.tgz", @@ -2267,6 +2289,27 @@ "@jridgewell/sourcemap-codec": "^1.6.0" } }, + "node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/minizlib": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-3.1.0.tgz", + "integrity": "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==", + "license": "MIT", + "dependencies": { + "minipass": "^7.1.2" + }, + "engines": { + "node": ">= 18" + } + }, "node_modules/nanoid": { "version": "3.3.20", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.20.tgz", @@ -2458,6 +2501,22 @@ "safe-buffer": "~5.2.0" } }, + "node_modules/tar": { + "version": "7.5.22", + "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.22.tgz", + "integrity": "sha512-MFO/QzvtAOmJbkhOaCTvbGcFN9L9b+JunIsDwaKljSOdcLMea3NJ1k9Usz/rjdfSXTq4dfzfeS7W4p4YOAAHeA==", + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/fs-minipass": "^4.0.0", + "chownr": "^3.0.0", + "minipass": "^7.1.2", + "minizlib": "^3.1.0", + "yallist": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/tinybench": { "version": "6.2.2", "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-6.2.2.tgz", @@ -2743,6 +2802,15 @@ "node": ">=20.11" } }, + "node_modules/yallist": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz", + "integrity": "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, "node_modules/zod": { "version": "4.6.7", "resolved": "https://registry.npmjs.org/zod/-/zod-4.6.7.tgz", diff --git a/package.json b/package.json index a67909a..4f1e3f8 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,7 @@ "@aws-sdk/client-s3": "^3.1152.0", "@aws-sdk/lib-storage": "^3.1152.0", "commander": "^15.0.0", + "tar": "^7.5.22", "zod": "^4.6.7" }, "devDependencies": { diff --git a/plans/README.md b/plans/README.md index fa0670d..e3e2345 100644 --- a/plans/README.md +++ b/plans/README.md @@ -47,7 +47,7 @@ Fazlar tablodaki sırayla uygulanır; sıranın tek kaynağı bu tablodur. Her f | 1 | İskelet, config ve `list` | Tamamlandı | [faz-01-iskelet-ve-list.md](faz-01-iskelet-ve-list.md) | | 2 | Mirror, snapshot ve `local` hedefi | Tamamlandı | [faz-02-mirror-ve-local.md](faz-02-mirror-ve-local.md) | | 3 | `s3` hedefi | Tamamlandı | [faz-03-s3.md](faz-03-s3.md) | -| 4 | Git LFS | Bekliyor | [faz-04-lfs.md](faz-04-lfs.md) | +| 4 | Git LFS | Tamamlandı | [faz-04-lfs.md](faz-04-lfs.md) | | 5 | Dokümantasyon ve son kontrol | Bekliyor | [faz-05-dokuman-ve-son-kontrol.md](faz-05-dokuman-ve-son-kontrol.md) | ## Çalışma kuralları diff --git a/plans/faz-04-lfs.md b/plans/faz-04-lfs.md index 1f7798a..bb19f5e 100644 --- a/plans/faz-04-lfs.md +++ b/plans/faz-04-lfs.md @@ -1,6 +1,6 @@ # Faz 4 — Git LFS -**Durum:** Bekliyor +**Durum:** Tamamlandı ## Amaç @@ -13,7 +13,7 @@ LFS kullanan repoların LFS nesneleri de yedeklenir: her yeni snapshot'ın yanı ## Kapsam - Ön koşul: LFS açıksa (`lfs: true`, varsayılan) `git lfs version` başarısızsa çıkış 2 ve mesaj: git-lfs kurulum linki (https://git-lfs.com) + "or rerun with --no-lfs to skip LFS objects". -- Mirror sonrası `git lfs fetch --all origin` (kaynağın `gitEnv()`'i ile). +- Yeni snapshot gerektiğinde (bundle sonrası) `git lfs fetch --all origin` (kaynağın `gitEnv()`'i ile); wiki birimlerinde LFS yoktur. - Snapshot: mirror'daki `lfs/objects` dizini boş değilse `tar` paketiyle `lfs/objects/...` yolunu koruyan `.lfs.tar`; manifest'e `lfsTarBytes`. - Ref'ler değişmediyse LFS ayrıca kontrol edilmez (LFS nesneleri ref'lere bağlıdır). - `--no-lfs` / `lfs: false` / `GHBACKUP_LFS=false`: LFS adımları atlanır, ön koşul kontrol edilmez. @@ -25,17 +25,17 @@ LFS kullanan repoların LFS nesneleri de yedeklenir: her yeni snapshot'ın yanı ## Görevler -- [ ] Doğrulama: `git lfs fetch --all` komutunun bare repoda çalıştığını ve nesneleri `/lfs/objects` altına yazdığını resmi kaynaktan veya deneyle doğrula; farklıysa mimari.md ve bu fazı güncelle. -- [ ] Doğrulama: yerel test için LFS sunucusu gerektirmeyen bir senaryo bul (ör. `lfs.url` ile `file://` LFS deposu, git-lfs ≥ 3 standalone file transfer); yoksa entegrasyon testini atlanabilir yap ve elle doğrulamaya bırak. -- [ ] LFS ön koşulu ve `--no-lfs` + birim testleri. -- [ ] Mirror'a `git lfs fetch --all`. -- [ ] `lfs.tar` üretimi, iki hedefe yükleme, saklamada birlikte silme. -- [ ] Entegrasyon testi (mümkünse): LFS dosyalı repo → snapshot → geri yükleme → dosya içeriği doğru. -- [ ] CI'da `git-lfs` kurulu olduğundan emin ol (ubuntu runner'da yoksa `apt-get install git-lfs`). +- [x] Doğrulama: `git lfs fetch --all` komutunun bare repoda çalıştığını ve nesneleri `/lfs/objects` altına yazdığını resmi kaynaktan veya deneyle doğrula; farklıysa mimari.md ve bu fazı güncelle. +- [x] Doğrulama: yerel test için LFS sunucusu gerektirmeyen bir senaryo bul (ör. `lfs.url` ile `file://` LFS deposu, git-lfs ≥ 3 standalone file transfer); yoksa entegrasyon testini atlanabilir yap ve elle doğrulamaya bırak. +- [x] LFS ön koşulu ve `--no-lfs` + birim testleri. +- [x] Mirror'a `git lfs fetch --all`. +- [x] `lfs.tar` üretimi, iki hedefe yükleme, saklamada birlikte silme. +- [x] Entegrasyon testi (mümkünse): LFS dosyalı repo → snapshot → geri yükleme → dosya içeriği doğru. +- [x] CI'da `git-lfs` kurulu olduğundan emin ol (ubuntu runner'da yoksa `apt-get install git-lfs`). ## 👤 Kullanıcı aksiyonları -- [ ] 💻 LFS kullanan bir repo varsa kendi makinende yedekleyip README'deki geri yükleme adımlarıyla bir LFS dosyasını açarak doğrula. +- [ ] 💻 LFS kullanan gerçek bir GitHub reposunu kendi makinende yedekle (`github-backup backup --local ~/git-backup-test --include '/'`) ve aşağıdaki geri yükleme adımlarıyla bir LFS dosyasını aç, sha256'sını orijinalle karşılaştır: gerçek `gh` oturumu ve gerçek bir LFS sunucusu gerekir, bulutta yok. ## Kabul kriterleri @@ -49,4 +49,18 @@ LFS kullanan repoların LFS nesneleri de yedeklenir: her yeni snapshot'ın yanı ## Devam notları -_Henüz başlanmadı._ Faz 2'den devralınanlar için [faz-02-mirror-ve-local.md](faz-02-mirror-ve-local.md) **Devam notları**'na bak: LFS adımının `core/run.ts` içinde takılacağı yer, `lfsTar`/`lfsTarBytes` için hazır alanlar ve ön koşulun `commands/backup.ts`'teki yeri. +Faz tamamlandı; kalan tek iş yukarıdaki 💻 maddesi (gerçek GitHub LFS reposuyla elle doğrulama). + +Yapılan: `src/core/lfs.ts` (`fetchLfs`, `hasLfsObjects`, `createLfsTar`), `core/run.ts` (`BackupInput.lfs`, LFS yalnızca yeni snapshot gerektiğinde ve `kind === "repo"` için), `commands/backup.ts` (`checkLfs`, `git lfs version` başarısızsa çıkış 2), `test/integration/lfs.test.ts` (sunucusuz düzen: `lfs.url=file://` + `lfs.standalonetransferagent=lfs-standalone-file`), CI'da `git-lfs` kontrolü. + +Doğrulananlar: `git lfs fetch --all origin` bare mirror'da çalışır ve nesneleri `/lfs/objects` altına yazar; LFS'siz repoda çıkış 0 verir ve `lfs/` boş kalır (bu yüzden `hasLfsObjects` dizinin değil dosyanın varlığına bakar). + +Faz 5 README'si için geri yükleme adımları (entegrasyon testindekiyle aynı): + +```bash +git clone .bundle restored +tar -xf .lfs.tar -C restored/.git # lfs/objects/... klonun .git/lfs/objects altına açılır +cd restored && git lfs install --local && git lfs checkout +``` + +`.lfs.tar` yalnızca repoda LFS nesnesi varsa vardır; wiki'lerde hiç yoktur. Test düzenini kuran yardımcılar `test/helpers/git.ts` içinde: `enableLfs`, `hasGitLfs`. diff --git a/plans/faz-05-dokuman-ve-son-kontrol.md b/plans/faz-05-dokuman-ve-son-kontrol.md index cbc554c..ec28bd1 100644 --- a/plans/faz-05-dokuman-ve-son-kontrol.md +++ b/plans/faz-05-dokuman-ve-son-kontrol.md @@ -8,7 +8,7 @@ Kullanıcı README'yi okuyarak aracı kurup, yapılandırıp, cron ile zamanlay ## Ön koşullar -- Faz 4 tamamlandı. +- Faz 4 tamamlandı. Geri yükleme (LFS dahil) adımları [faz-04-lfs.md](faz-04-lfs.md) **Devam notları**'nda. ## Kapsam diff --git a/plans/kararlar.md b/plans/kararlar.md index 7d4ab6f..565b13f 100644 --- a/plans/kararlar.md +++ b/plans/kararlar.md @@ -40,3 +40,4 @@ Projenin güncel kararları. Bir karar değişirse satırı güncellenir ve tari | K-034 | 2026-10-14 | Wiki ayrı birimdir (`.wiki` anahtarı, kendi mirror'ı ve snapshot'ları). Wiki'de "repository not found" `skipped: no wiki`; ref'siz mirror `skipped: empty`. | Wiki açık ama sayfasız repolar hata üretmesin; wiki yedeği ana repodan bağımsız saklanır. | | K-035 | 2026-10-14 | S3 istemcisi `requestChecksumCalculation` ve `responseChecksumValidation` için `WHEN_REQUIRED` kullanır. | AWS SDK JS v3'ün varsayılan CRC32 sağlama toplamı R2, B2 gibi üçüncü parti servislerde imza/başlık hatası verebilir; yalnızca API'nin zorunlu kıldığı yerde (ör. `DeleteObjects`) eklenir. | | K-036 | 2026-10-14 | `Target` arayüzünde isteğe bağlı `checkAccess()` vardır; `backup` her hedef için kilitten önce çağırır ve hata çıkış 2 verir (`s3`: `HeadBucket`). Yarım multipart temizliği en iyi çaba esaslıdır. | Yanlış kimlik/bucket ilk repoda değil başta anlaşılsın; multipart listelemeyi desteklemeyen servisler saklama adımını bozmasın. | +| K-037 | 2026-10-14 | LFS yalnızca yeni snapshot gerektiğinde (ref özeti hedeflerden birinde değiştiğinde) ve yalnızca repo birimleri için işlenir: bundle sonrası `git lfs fetch --all origin`, `lfs/objects` altında dosya varsa `snapshot.lfs.tar` (`tar` paketiyle) ve manifest'e `lfsTarBytes`. Wiki'lerde LFS yoktur. | LFS nesneleri ref'lere bağlıdır; ref'ler değişmediyse ağa gitmek gereksizdir. Snapshot yalnızca tüm adımlar başarılıysa yazıldığı için eksik LFS'li "unchanged" oluşmaz. | diff --git a/plans/mimari.md b/plans/mimari.md index 105a8fb..a08b2a6 100644 --- a/plans/mimari.md +++ b/plans/mimari.md @@ -94,13 +94,12 @@ test/ 4. Repolar listelenir ve filtrelenir. 5. Her repo (ve wiki'si) `concurrency` sınırıyla paralel işlenir: 1. **Mirror:** `///.git` yoksa `git init --bare`, `git remote add origin ` ve fetch refspec'leri `+refs/heads/*:refs/heads/*` ile `+refs/tags/*:refs/tags/*` olarak ayarlanır (`refs/pull/*` alınmaz). Sonra her çalıştırmada `remote.origin.url` güncellenir ve `git fetch --no-tags --no-prune origin` çalışır (tag'ler refspec'ten gelir). Ardından `git ls-remote --symref origin HEAD` ile mirror'ın `HEAD`'i uzağın varsayılan branch'ine ayarlanır (bundle `git clone` ile açıldığında checkout olsun diye; başarısız olursa sessizce geçilir). `isEmpty` olan repo atlanır (`skipped: empty`); fetch sonrası hiç ref yoksa da `skipped: empty`. İlk fetch başarısız olursa yeni oluşturulan boş mirror dizini silinir. - 2. **LFS:** LFS açıksa `git lfs fetch --all origin`. Repoda LFS nesnesi yoksa adım no-op'tur. - 3. **Ref özeti:** `git for-each-ref --format='%(objectname) %(refname)'` çıktısı ref adına göre sıralanır; satırların (` \n`) sha256'sı `refsHash` olur. - 4. **Hedef karşılaştırma:** her hedef için `latestManifest`; `refsHash` aynıysa o hedef için `unchanged`. - 5. **Snapshot:** en az bir hedef değişiklik istiyorsa `/tmp/unit-*/` altında `git bundle create --all` ve LFS nesnesi varsa `lfs/objects` klasörünün tar'ı oluşturulur. Zaman damgası bu adımda alınır ve tüm hedeflere aynıdır. - 6. **Yükleme:** değişiklik isteyen her hedefe `putSnapshot`. Sıra: önce `.bundle` ve `.lfs.tar`, en son `.json` manifest (manifest, snapshot'ın tamamlandığının işaretidir). - 7. **Saklama:** yeni snapshot yazılan her hedefte `applyRetention`: manifest'i olan snapshot'lar zamana göre sıralanır, `keep`'ten eskiler (bundle, lfs.tar, json) silinir, ardından `cleanupIncomplete` manifest'i olmayan yarım dosyaları siler. Bir hedefin yazma veya saklama adımı hata verirse o hedef `failed` olur, diğer hedefler etkilenmez. - 8. Geçici dosyalar silinir. + 2. **Ref özeti:** `git for-each-ref --format='%(objectname) %(refname)'` çıktısı ref adına göre sıralanır; satırların (` \n`) sha256'sı `refsHash` olur. + 3. **Hedef karşılaştırma:** her hedef için `latestManifest`; `refsHash` aynıysa o hedef için `unchanged`. + 4. **Snapshot:** en az bir hedef değişiklik istiyorsa `/tmp/unit-*/` altında `git bundle create --all` oluşturulur. LFS açıksa ve birim wiki değilse bundle'dan sonra `git lfs fetch --all origin` çalışır (bare mirror'da çalışır, nesneleri `/lfs/objects` altına yazar; LFS'siz repoda çıkış 0 ve no-op). `lfs/objects` altında en az bir dosya varsa (boş dizin sayılmaz) `lfs/objects/...` yolunu koruyan `snapshot.lfs.tar` üretilir ve manifest'e `lfsTarBytes` yazılır. LFS nesneleri ref'lere bağlı olduğundan ref'ler değişmediyse (adım 3) LFS'e hiç dokunulmaz; her snapshot'ın tar'ı tüm LFS nesnelerini içerir (K-018). Zaman damgası bu adımda alınır ve tüm hedeflere aynıdır. + 5. **Yükleme:** değişiklik isteyen her hedefe `putSnapshot`. Sıra: önce `.bundle` ve `.lfs.tar`, en son `.json` manifest (manifest, snapshot'ın tamamlandığının işaretidir). + 6. **Saklama:** yeni snapshot yazılan her hedefte `applyRetention`: manifest'i olan snapshot'lar zamana göre sıralanır, `keep`'ten eskiler (bundle, lfs.tar, json) silinir, ardından `cleanupIncomplete` manifest'i olmayan yarım dosyaları siler. Bir hedefin yazma veya saklama adımı hata verirse o hedef `failed` olur, diğer hedefler etkilenmez. + 7. Geçici dosyalar silinir. 6. Her adım (mirror, hedef sorgusu, bundle, yükleme, saklama) hata alırsa 2 kez yeniden denenir (1 sn, 5 sn bekleme; `withRetry`). Yine başarısızsa o birim `failed` olur, diğerleri devam eder. 7. Wiki: `wiki` açıkken `hasWiki` true ise `.wiki.git` ayrı bir birim olarak aynı adımlardan geçer (repoKey `//.wiki`, manifest `fullName` değeri `/.wiki`). Wiki fetch'i "repository not found" ile başarısız olursa (wiki açık ama sayfa yok) `skipped: no wiki`, hata sayılmaz; aynı hata ana repoda gerçek hatadır. Özet birim (repo veya wiki) başına sayar. 8. Sonda özet stderr'e: başarılı / değişmedi / atlanan / başarısız sayıları ve başarısızların listesi (listelenemeyen sahipler dahil). `--json` ile stdout'a `{ summary: { ok, unchanged, skipped, failed, interrupted }, failures: [{ repo, error, targets }], listFailures, results }` yazılır. @@ -216,7 +215,7 @@ Bayraklar (her iki komutta ortak olanlar dahil): - Birim (Vitest): config birleştirme ve öncelik, şema hataları, glob filtre, zaman damgası, ref özeti, saklama seçimi, `gh` çıktısının ayrıştırılması (sahte `exec` ile). - Entegrasyon (Vitest): geçici dizinde `git init --bare` ile "uzak" repolar; `file://` URL'leri döndüren test kaynağı; mirror → bundle → `local` hedef → `git clone ` ile geri yükleme doğrulaması; değişmeyen repo için yeni snapshot oluşmaması; saklama. -- LFS entegrasyon: `git-lfs` kuruluysa yerel LFS deposu olmadan çalışan senaryo; mümkün değilse birim + elle doğrulama. +- LFS entegrasyon (`test/integration/lfs.test.ts`, `git-lfs` yoksa atlanır): LFS sunucusu gerekmez. Test reposu `lfs.url=file://` ve `lfs.standalonetransferagent=lfs-standalone-file` (git-lfs'in yerleşik dosya aktarımı) ile yapılandırılır; mirror aynı ayarları `GIT_CONFIG_*` env'iyle (kaynağın `gitEnv()`'i) alır. Standalone ajan URL'nin bir git dizini olmasını ve dizinin var olmasını ister, nesneleri `/lfs/objects` altına yazar. Test bundle'dan klonlar, tar'ı klonun `.git/` dizinine açar, `git lfs checkout` yapar ve sha256'yı karşılaştırır. - S3 entegrasyon: `GHBACKUP_TEST_S3_ENDPOINT` (+ `GHBACKUP_TEST_S3_ACCESS_KEY_ID`, `GHBACKUP_TEST_S3_SECRET_ACCESS_KEY`, isteğe bağlı `GHBACKUP_TEST_S3_BUCKET`) tanımlıysa herhangi bir S3 uyumlu sunucuya karşı çalışır, değilse atlanır. CI'da `andrewgaul/s3proxy` (S3Proxy, filesystem backend) servis container'ı ile çalışır; kimlik `test-access`/`test-secret`, workflow'da sabit. Yerelde aynı sunucu Maven Central'daki `org.gaul:s3proxy` jar'ıyla (`java -jar s3proxy--jar-with-dependencies.jar --properties s3proxy.conf`) başlatılabilir. Birim testleri (`test/unit/s3-target.test.ts`) sahte bir istemciyle sayfalama, 1000'lik silme grupları ve hata maskelemeyi sunucusuz doğrular. - Gerçek GitHub hesabıyla uçtan uca deneme elle yapılır (💻). diff --git a/src/commands/backup.ts b/src/commands/backup.ts index 96ad99c..e85d8e8 100644 --- a/src/commands/backup.ts +++ b/src/commands/backup.ts @@ -21,6 +21,18 @@ async function checkGit(exec: Exec): Promise { } } +/** Fails with exit code 2 when Git LFS is enabled but `git-lfs` is missing. */ +async function checkLfs(exec: Exec): Promise { + try { + await exec("git", ["lfs", "version"]); + } catch { + throw new PrerequisiteError( + "git-lfs is not installed; install it from https://git-lfs.com " + + "or rerun with --no-lfs to skip LFS objects", + ); + } +} + /** Fails with exit code 2 when a target's storage is unreachable or rejects the credentials. */ async function checkTargets(targets: Target[]): Promise { for (const target of targets) { @@ -75,6 +87,7 @@ export async function runBackup(options: BackupOptions, deps: CommandDeps): Prom createTarget(target, { keep: resolveKeep(target.keep, config.keep) }), ); await checkGit(deps.exec); + if (config.lfs) await checkLfs(deps.exec); await checkTargets(targets); const controller = new AbortController(); @@ -101,6 +114,7 @@ export async function runBackup(options: BackupOptions, deps: CommandDeps): Prom cacheDir: config.cacheDir, concurrency: config.concurrency, wiki: config.wiki, + lfs: config.lfs, }, { exec: deps.exec, diff --git a/src/core/lfs.ts b/src/core/lfs.ts new file mode 100644 index 0000000..31449fa --- /dev/null +++ b/src/core/lfs.ts @@ -0,0 +1,39 @@ +import type { Dirent } from "node:fs"; +import { readdir, stat } from "node:fs/promises"; +import path from "node:path"; +import { create } from "tar"; +import type { Git } from "./git.js"; + +/** Directory of a bare mirror that holds the LFS objects, relative to the mirror. */ +const LFS_OBJECTS = "lfs/objects"; + +/** Fetches the LFS objects of every ref into `/lfs/objects`. A repo without LFS is a no-op. */ +export async function fetchLfs(git: Git, mirrorDir: string): Promise { + await git(["lfs", "fetch", "--all", "origin"], { cwd: mirrorDir }); +} + +async function containsFile(dir: string): Promise { + let entries: Dirent[]; + try { + entries = await readdir(dir, { withFileTypes: true }); + } catch (err) { + if ((err as NodeJS.ErrnoException).code === "ENOENT") return false; + throw err; + } + for (const entry of entries) { + if (entry.isFile()) return true; + if (entry.isDirectory() && (await containsFile(path.join(dir, entry.name)))) return true; + } + return false; +} + +/** True when the mirror holds at least one LFS object (an empty `lfs/objects` does not count). */ +export function hasLfsObjects(mirrorDir: string): Promise { + return containsFile(path.join(mirrorDir, ...LFS_OBJECTS.split("/"))); +} + +/** Writes `lfs/objects/...` of the mirror to a tar and returns its size in bytes. */ +export async function createLfsTar(mirrorDir: string, file: string): Promise { + await create({ cwd: mirrorDir, file, portable: true }, [LFS_OBJECTS]); + return (await stat(file)).size; +} diff --git a/src/core/run.ts b/src/core/run.ts index 637db8b..e92cf02 100644 --- a/src/core/run.ts +++ b/src/core/run.ts @@ -8,6 +8,7 @@ import type { Logger } from "../util/log.js"; import { formatStamp, sleep } from "../util/time.js"; import type { ToolInfo } from "../util/tool.js"; import { createGit } from "./git.js"; +import { createLfsTar, fetchLfs, hasLfsObjects } from "./lfs.js"; import { syncMirror } from "./mirror.js"; import { applyRetention } from "./retention.js"; import { buildManifest, createBundle, hashRefs, listRefs } from "./snapshot.js"; @@ -51,6 +52,8 @@ export interface BackupInput { cacheDir: string; concurrency: number; wiki: boolean; + /** Also back up Git LFS objects (repositories only, not wikis). */ + lfs: boolean; } export interface BackupContext { @@ -176,6 +179,17 @@ async function processUnit( tmpDir = await mkdtemp(path.join(input.cacheDir, "tmp", "unit-")); const bundle = path.join(tmpDir, "snapshot.bundle"); const bundleBytes = await retry(() => createBundle(git, unit.mirrorDir, bundle)); + let lfsTar: string | undefined; + let lfsTarBytes: number | undefined; + // LFS objects belong to refs, so they are only fetched when a new snapshot is due. + if (input.lfs && unit.kind === "repo") { + await retry(() => fetchLfs(git, unit.mirrorDir)); + if (await hasLfsObjects(unit.mirrorDir)) { + lfsTar = path.join(tmpDir, "snapshot.lfs.tar"); + const file = lfsTar; + lfsTarBytes = await retry(() => createLfsTar(unit.mirrorDir, file)); + } + } const stamp = formatStamp((ctx.now ?? (() => new Date()))()); const manifest = buildManifest({ source: unit.repo.source, @@ -183,11 +197,18 @@ async function processUnit( stamp, refs, bundleBytes, + ...(lfsTarBytes === undefined ? {} : { lfsTarBytes }), tool: ctx.tool, }); for (const target of pending) { try { - await retry(() => target.putSnapshot(unit.key, stamp, { bundle, manifest })); + await retry(() => + target.putSnapshot(unit.key, stamp, { + bundle, + ...(lfsTar ? { lfsTar } : {}), + manifest, + }), + ); await retry(() => applyRetention(target, unit.key)); results.push({ target: target.name, status: "created", stamp }); log.info(`snapshot ${stamp} written to ${target.name}`, unit.key); diff --git a/test/helpers/git.ts b/test/helpers/git.ts index 1b323de..94fbdee 100644 --- a/test/helpers/git.ts +++ b/test/helpers/git.ts @@ -119,3 +119,40 @@ export function fakeSource(repos: RepoInfo[] = [], env: Record = gitEnv: () => env, }; } + +/** True when `git-lfs` is installed, so LFS tests can be skipped on machines without it. */ +export function hasGitLfs(): boolean { + try { + execFileSync("git", ["lfs", "version"], { stdio: "ignore" }); + return true; + } catch { + return false; + } +} + +/** + * Turns a test remote into an LFS repository whose LFS objects live in a local bare repository + * (git-lfs' built-in standalone file transfer, so no LFS server is needed). Returns the git + * environment a mirror needs to fetch those objects. + */ +export function enableLfs( + remote: TestRemote, + storeDir: string, + pattern = "*.bin", +): Record { + const config = { + "lfs.url": `file://${storeDir}`, + "lfs.standalonetransferagent": "lfs-standalone-file", + }; + remote.git(["init", "--bare", storeDir], path.dirname(storeDir)); + remote.git(["lfs", "install", "--local"]); + remote.git(["lfs", "track", pattern]); + for (const [key, value] of Object.entries(config)) remote.git(["config", key, value]); + remote.git(["add", ".gitattributes"]); + const env: Record = { GIT_CONFIG_COUNT: String(Object.keys(config).length) }; + Object.entries(config).forEach(([key, value], index) => { + env[`GIT_CONFIG_KEY_${index}`] = key; + env[`GIT_CONFIG_VALUE_${index}`] = value; + }); + return env; +} diff --git a/test/integration/lfs.test.ts b/test/integration/lfs.test.ts new file mode 100644 index 0000000..c9fe995 --- /dev/null +++ b/test/integration/lfs.test.ts @@ -0,0 +1,163 @@ +import { createHash } from "node:crypto"; +import { existsSync, readdirSync, readFileSync, rmSync, statSync } from "node:fs"; +import path from "node:path"; +import { extract } from "tar"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import type { CommandDeps } from "../../src/commands/context.js"; +import { createProgram } from "../../src/program.js"; +import type { RepoInfo } from "../../src/sources/types.js"; +import type { Manifest } from "../../src/targets/types.js"; +import type { Exec } from "../../src/util/exec.js"; +import { + enableLfs, + fakeSource, + hasGitLfs, + isolatedExec, + makeTempDir, + repoInfo, + TestRemote, +} from "../helpers/git.js"; + +let root: string; +let home: string; +let clock: number; + +beforeEach(() => { + root = makeTempDir("ghb-lfs-run-"); + home = path.join(root, "home"); + clock = Date.UTC(2026, 0, 1); +}); +afterEach(() => rmSync(root, { recursive: true, force: true })); + +/** Remote with one LFS-tracked file (`big.bin`) and one plain file. */ +function lfsRemote(name: string, content = `lfs payload ${"x".repeat(4000)}`) { + const remote = new TestRemote(root, name, home); + const env = enableLfs(remote, path.join(root, `${name}-lfs-store.git`)); + remote.commit("plain.txt", "not in lfs"); + remote.commit("big.bin", content); + return { remote, env, content }; +} + +function setup(repos: RepoInfo[], env: Record, exec?: Exec) { + const lfsFetches: string[] = []; + const base = isolatedExec(home); + const deps: CommandDeps = { + exec: (cmd, args, opts) => { + if (args[0] === "lfs" && args[1] === "fetch") lfsFetches.push(opts?.cwd ?? ""); + return (exec ?? base)(cmd, args, opts); + }, + env: {}, + stdout: () => {}, + stderr: () => {}, + homeDir: home, + cwd: root, + createSource: () => fakeSource(repos, env), + now: () => { + clock += 1000; + return new Date(clock); + }, + sleep: async () => {}, + }; + const backup = (...flags: string[]) => + createProgram(deps).run([ + "backup", + "--local", + path.join(root, "out"), + "--cache-dir", + path.join(root, "cache"), + ...flags, + ]); + return { backup, lfsFetches }; +} + +const snapshotDir = (key: string) => path.join(root, "out", key); +const list = (key: string) => + existsSync(snapshotDir(key)) ? readdirSync(snapshotDir(key)).sort() : []; +const sha256 = (data: string | Buffer) => createHash("sha256").update(data).digest("hex"); + +describe.skipIf(!hasGitLfs())("backup with Git LFS", () => { + it("writes lfs.tar and lfsTarBytes, and the restored file matches the original", async () => { + const { remote, env, content } = lfsRemote("a"); + const { backup } = setup([repoInfo("o/a", remote)], env); + expect(await backup()).toBe(0); + + const files = list("test/o/a"); + expect(files.map((f) => f.replace(/^\d{8}T\d{6}Z/, ""))).toEqual([ + ".bundle", + ".json", + ".lfs.tar", + ]); + const dir = snapshotDir("test/o/a"); + const byExt = (ext: string) => path.join(dir, files.find((f) => f.endsWith(ext)) as string); + const manifest = JSON.parse(readFileSync(byExt(".json"), "utf8")) as Manifest; + expect(manifest.lfsTarBytes).toBe(statSync(byExt(".lfs.tar")).size); + + // Restore as documented: clone the bundle, unpack the tar into .git, check out LFS files. + const clone = path.join(root, "restored"); + const exec = isolatedExec(home); + await exec("git", ["clone", byExt(".bundle"), clone]); + await extract({ file: byExt(".lfs.tar"), cwd: path.join(clone, ".git") }); + await exec("git", ["lfs", "install", "--local"], { cwd: clone }); + await exec("git", ["lfs", "checkout"], { cwd: clone }); + expect(sha256(readFileSync(path.join(clone, "big.bin")))).toBe(sha256(content)); + expect(readFileSync(path.join(clone, "plain.txt"), "utf8")).toBe("not in lfs"); + }); + + it("writes no lfs.tar for a repository without LFS objects", async () => { + const plain = new TestRemote(root, "plain", home); + plain.commit("a.txt", "1"); + const { backup } = setup([repoInfo("o/plain", plain)], {}); + expect(await backup()).toBe(0); + + const files = list("test/o/plain"); + expect(files).toHaveLength(2); + expect(files.some((f) => f.endsWith(".lfs.tar"))).toBe(false); + const json = files.find((f) => f.endsWith(".json")) as string; + const manifest = JSON.parse(readFileSync(path.join(snapshotDir("test/o/plain"), json), "utf8")); + expect(manifest).not.toHaveProperty("lfsTarBytes"); + }); + + it("skips every LFS step with --no-lfs", async () => { + const { remote, env } = lfsRemote("a"); + const { backup, lfsFetches } = setup([repoInfo("o/a", remote)], env); + expect(await backup("--no-lfs")).toBe(0); + + expect(lfsFetches).toEqual([]); + expect(list("test/o/a").some((f) => f.endsWith(".lfs.tar"))).toBe(false); + expect(existsSync(path.join(root, "cache", "test", "o", "a.git", "lfs", "objects"))).toBe( + false, + ); + }); + + it("does not touch LFS when refs are unchanged and fetches again after a new commit", async () => { + const { remote, env } = lfsRemote("a"); + const { backup, lfsFetches } = setup([repoInfo("o/a", remote)], env); + expect(await backup()).toBe(0); + expect(lfsFetches).toHaveLength(1); + + expect(await backup()).toBe(0); + expect(lfsFetches).toHaveLength(1); + + remote.commit("big.bin", `changed ${"y".repeat(4000)}`); + expect(await backup()).toBe(0); + expect(lfsFetches).toHaveLength(2); + // The second snapshot carries all LFS objects, old and new (K-018: self-contained snapshots). + const tars = list("test/o/a").filter((f) => f.endsWith(".lfs.tar")); + expect(tars).toHaveLength(2); + const sizes = tars.map((f) => statSync(path.join(snapshotDir("test/o/a"), f)).size); + expect(sizes[1]).toBeGreaterThan(sizes[0] as number); + }); + + it("does not run LFS for wikis", async () => { + const { remote, env } = lfsRemote("a"); + const wiki = new TestRemote(root, "a.wiki-src", home); + wiki.commit("Home.md", "hi"); + const { backup, lfsFetches } = setup( + [repoInfo("o/a", remote, { hasWiki: true, wikiUrl: wiki.url })], + env, + ); + expect(await backup()).toBe(0); + expect(lfsFetches).toHaveLength(1); + expect(list("test/o/a.wiki").some((f) => f.endsWith(".lfs.tar"))).toBe(false); + }); +}); diff --git a/test/unit/commands.test.ts b/test/unit/commands.test.ts index d194062..c2f763f 100644 --- a/test/unit/commands.test.ts +++ b/test/unit/commands.test.ts @@ -228,6 +228,37 @@ describe("backup", () => { const { run } = setup(); expect(await run(["backup", "--bogus"])).toBe(2); }); + + describe("git-lfs prerequisite", () => { + const withoutLfs = (calls: string[][]): Exec => { + return async (_cmd, args) => { + calls.push(args); + if (args[0] === "lfs") { + throw new ExecError("git: 'lfs' is not a git command", "git", 1, "not a git command"); + } + return { stdout: "", stderr: "" }; + }; + }; + const lfsCalls = (calls: string[][]) => calls.filter((args) => args[0] === "lfs"); + + it("exits 2 with install and --no-lfs instructions when git-lfs is missing", async () => { + const calls: string[][] = []; + const { run, err } = setup({ exec: withoutLfs(calls) }); + expect(await run(["backup", "--local", path.join(home, "out")])).toBe(2); + expect(err.join("\n")).toContain("https://git-lfs.com"); + expect(err.join("\n")).toContain("rerun with --no-lfs to skip LFS objects"); + }); + + it.each([ + ["--no-lfs", {}, ["--no-lfs"]], + ["GHBACKUP_LFS=false", { GHBACKUP_LFS: "false" }, []], + ])("is not checked with %s", async (_name, env, flags) => { + const calls: string[][] = []; + const { run } = setup({ exec: withoutLfs(calls), env }); + expect(await run(["backup", "--local", path.join(home, "out"), ...flags])).toBe(0); + expect(lfsCalls(calls)).toEqual([]); + }); + }); }); describe("program", () => { diff --git a/test/unit/lfs.test.ts b/test/unit/lfs.test.ts new file mode 100644 index 0000000..f2c8ce4 --- /dev/null +++ b/test/unit/lfs.test.ts @@ -0,0 +1,46 @@ +import { mkdirSync, rmSync, writeFileSync } from "node:fs"; +import path from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import type { Git } from "../../src/core/git.js"; +import { fetchLfs, hasLfsObjects } from "../../src/core/lfs.js"; +import { makeTempDir } from "../helpers/git.js"; + +let mirror: string; + +beforeEach(() => { + mirror = makeTempDir("ghb-lfs-"); +}); +afterEach(() => rmSync(mirror, { recursive: true, force: true })); + +describe("hasLfsObjects", () => { + const cases: Array<[string, string[], boolean]> = [ + ["no lfs directory", [], false], + ["empty lfs/objects", ["lfs/objects/"], false], + ["only empty fan-out directories", ["lfs/objects/ab/cd/"], false], + ["lfs/tmp files do not count", ["lfs/tmp/partial"], false], + ["an object file", ["lfs/objects/ab/cd/abcd1234"], true], + ]; + it.each(cases)("%s", async (_name, entries, expected) => { + for (const entry of entries) { + const target = path.join(mirror, ...entry.split("/")); + if (entry.endsWith("/")) mkdirSync(target, { recursive: true }); + else { + mkdirSync(path.dirname(target), { recursive: true }); + writeFileSync(target, "x"); + } + } + expect(await hasLfsObjects(mirror)).toBe(expected); + }); +}); + +describe("fetchLfs", () => { + it("runs `git lfs fetch --all origin` in the mirror", async () => { + const calls: Array<{ args: string[]; cwd?: string }> = []; + const git: Git = async (args, opts) => { + calls.push({ args, cwd: opts?.cwd }); + return { stdout: "", stderr: "" }; + }; + await fetchLfs(git, mirror); + expect(calls).toEqual([{ args: ["lfs", "fetch", "--all", "origin"], cwd: mirror }]); + }); +});