diff --git a/HenriqueMC17/README.md b/HenriqueMC17/README.md new file mode 100644 index 0000000..207aba1 --- /dev/null +++ b/HenriqueMC17/README.md @@ -0,0 +1,92 @@ +# 🌌 µgit (ugit) — Um Git Minimalista de Alta Fidelidade + +Uma implementação compacta e de alta fidelidade do Git escrita do zero em **TypeScript** e **Node.js**, utilizando compactação nativa e o algoritmo de comparação de **Myers** (*Myers Diff Algorithm*). + +--- + +## 🛠️ Stack Tecnológica + +* **Linguagem & Ambiente:** TypeScript (Node.js) para tipagem estática robusta e modularização limpa. +* **Armazenamento e Hashing:** Algoritmo SHA-1 nativo via módulo `crypto` do Node. +* **Compactação:** Deflate/Inflate nativo do Zlib para persistir os snapshots em arquivos compactados, replicando o comportamento interno do Git clássico. +* **Algoritmo do Diff:** Implementação manual do algoritmo guloso de **Myers** para cálculo de menor sequência de edição (*Shortest Edit Script*). + +--- + +## 🏛️ Arquitetura de Snapshot e Armazenamento + +Em conformidade com a arquitetura interna do Git, o `ugit` trata arquivos como dados endereçáveis por conteúdo (*content-addressable storage*): + +```text +.ugit/ + ├── HEAD <-- Ponteiro para o commit ativo (SHA-1 em texto puro) + ├── index <-- JSON que mapeia os caminhos dos arquivos para seus respectivos blobs staged + └── objects/ + ├── 4b/ + │ └── 825dc... <-- Objetos (blobs, trees ou commits) indexados pelos 2 primeiros caracteres do SHA-1 + └── ... +``` + +### O Modelo de Objetos (Object Model) + +Cada arquivo gravado no repositório é compactado e armazenado na pasta `.ugit/objects/` em arquivos cujos nomes correspondem ao hash SHA-1 de 40 caracteres (dividido em subpastas de 2 caracteres). O cabeçalho dos arquivos segue a convenção do Git clássico: ` \0`. + +* **Blob:** Salva apenas o snapshot do conteúdo puro de um arquivo. +* **Tree:** Salva a estrutura de diretórios recursiva. O `ugit` constrói e resolve árvores de diretórios recursivamente. Cada entrada em uma árvore contém as permissões (`100644` ou `040000`), tipo (`blob` ou `tree`), hash SHA-1 e o nome do arquivo/pasta correspondente. +* **Commit:** Salva os metadados contendo o hash da árvore raiz (`tree`), commits pais (`parent`), nome do autor, timestamp Epoch e a mensagem de commit. + +--- + +## 🧬 Abordagem do Diff: Algoritmo de Myers + +Diferente de implementações simples de diff linha a linha que sofrem em casos de ambiguidade, o `ugit` implementa o **Myers Diff Algorithm**. + +* O algoritmo funciona mapeando o problema de encontrar a menor diferença de texto como a busca de um caminho mínimo em uma grade bidimensional (DAG). +* Realiza uma busca em largura (*Breadth-First Search*) otimizada sobre as diagonais da grade para encontrar o caminho com menor custo de edições (inserções e remoções). +* Uma vez encontrado o ponto final, reconstrói o caminho backtrackando através do histórico das diagonais alcançadas, garantindo um diff limpo, coeso e altamente legível. + +--- + +## ⚡ Exemplo Prático de Uso + +### 1. Pré-requisitos & Compilação + +Instale as dependências de desenvolvimento do TypeScript e compile o projeto para JavaScript puro: + +```bash +npm install +npm run build +``` + +### 2. Fluxo Básico do Terminal + +```bash +# Inicializa o repositório µgit +node dist/index.js init + +# Cria um arquivo de teste +echo "Olá Mundo!" > teste.txt + +# Adiciona à staging area +node dist/index.js add teste.txt + +# Verifica o status +node dist/index.js status + +# Commita a alteração +node dist/index.js commit -m "Meu primeiro commit com ugit" + +# Mostra o histórico +node dist/index.js log + +# Modifica o arquivo e roda o diff +echo "Olá Mundo com Myers!" > teste.txt +node dist/index.js diff +``` + +--- + +## 🧠 Aprendizados + +* **O que funcionou:** A modularidade do TypeScript se mostrou excelente para organizar as responsabilidades (divisão de responsabilidades entre CLI parser, armazenamento de objetos e staging area). A compactação zlib nativa se encaixou perfeitamente no formato tradicional e garantiu que o projeto se comportasse de forma idêntica à especificação interna do Git. +* **O que mudaria em uma versão 2.0:** A staging area (`index`) foi simplificada como um arquivo JSON para facilitar a depuração. Em uma versão comercial de grande escala, usaríamos o formato de index binário do Git com cabeçalho de checksum SHA-1 para otimizar a velocidade de E/S de arquivos sob diretórios gigantes. diff --git a/HenriqueMC17/package.json b/HenriqueMC17/package.json new file mode 100644 index 0000000..f679d48 --- /dev/null +++ b/HenriqueMC17/package.json @@ -0,0 +1,20 @@ +{ + "name": "ugit", + "version": "1.0.0", + "description": "A micro-Git implementation in TypeScript showcasing Myers Diff and content-addressable storage", + "main": "dist/index.js", + "bin": { + "ugit": "./dist/index.js" + }, + "scripts": { + "build": "tsc", + "start": "node dist/index.js", + "ugit": "ts-node src/index.ts" + }, + "dependencies": {}, + "devDependencies": { + "@types/node": "^20.11.24", + "ts-node": "^10.9.2", + "typescript": "^5.3.3" + } +} diff --git a/HenriqueMC17/src/index-manager.ts b/HenriqueMC17/src/index-manager.ts new file mode 100644 index 0000000..35a8dff --- /dev/null +++ b/HenriqueMC17/src/index-manager.ts @@ -0,0 +1,87 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import { Index } from './types'; +import { ObjectStore } from './object-store'; + +export class IndexManager { + private static getIndexPath(repoRoot: string): string { + return path.join(repoRoot, '.ugit', 'index'); + } + + /** + * Carrega o arquivo index (staging area). + */ + public static readIndex(repoRoot: string): Index { + const indexPath = this.getIndexPath(repoRoot); + if (!fs.existsSync(indexPath)) { + return { entries: {} }; + } + try { + const content = fs.readFileSync(indexPath, 'utf-8'); + return JSON.parse(content) as Index; + } catch { + return { entries: {} }; + } + } + + /** + * Grava as alterações de volta no arquivo index. + */ + public static writeIndex(repoRoot: string, index: Index): void { + const indexPath = this.getIndexPath(repoRoot); + fs.writeFileSync(indexPath, JSON.stringify(index, null, 2), 'utf-8'); + } + + /** + * Adiciona um arquivo específico (ou todos via recursividade) à staging area. + */ + public static add(repoRoot: string, targetPath: string): void { + const absolutePath = path.resolve(repoRoot, targetPath); + + if (!fs.existsSync(absolutePath)) { + throw new Error(`Caminho inexistente: ${targetPath}`); + } + + const index = this.readIndex(repoRoot); + const stats = fs.statSync(absolutePath); + + if (stats.isFile()) { + this.stageFile(repoRoot, index, absolutePath); + } else if (stats.isDirectory()) { + this.stageDirectory(repoRoot, index, absolutePath); + } + + this.writeIndex(repoRoot, index); + } + + private static stageFile(repoRoot: string, index: Index, absoluteFilePath: string): void { + // Ignora a própria pasta do .ugit e o node_modules + if (absoluteFilePath.includes(path.join(repoRoot, '.ugit')) || absoluteFilePath.includes('node_modules')) { + return; + } + + const relativePath = path.relative(repoRoot, absoluteFilePath).replace(/\\/g, '/'); + const content = fs.readFileSync(absoluteFilePath); + + // Grava o objeto blob + const sha1 = ObjectStore.writeObject(repoRoot, 'blob', content); + index.entries[relativePath] = sha1; + } + + private static stageDirectory(repoRoot: string, index: Index, absoluteDirPath: string): void { + const files = fs.readdirSync(absoluteDirPath); + for (const file of files) { + const fullPath = path.join(absoluteDirPath, file); + const stats = fs.statSync(fullPath); + + if (stats.isFile()) { + this.stageFile(repoRoot, index, fullPath); + } else if (stats.isDirectory()) { + if (file === '.ugit' || file === 'node_modules' || file === '.git') { + continue; + } + this.stageDirectory(repoRoot, index, fullPath); + } + } + } +} diff --git a/HenriqueMC17/src/index.ts b/HenriqueMC17/src/index.ts new file mode 100644 index 0000000..7943dfa --- /dev/null +++ b/HenriqueMC17/src/index.ts @@ -0,0 +1,295 @@ +#!/usr/bin/env node + +import * as fs from 'fs'; +import * as path from 'path'; +import { Repository } from './repository'; +import { IndexManager } from './index-manager'; +import { ObjectStore } from './object-store'; +import { myersDiff, DiffLine } from './myers-diff'; + +function printHelp(): void { + console.log(` +\x1b[36mµgit (ugit) - Um Git Minimalista em TypeScript\x1b[0m + +Uso: + ugit init - Inicializa um novo repositório + ugit add - Adiciona arquivos para a staging area + ugit commit -m "" - Cria um snapshot dos arquivos staged + ugit log - Exibe o histórico de commits + ugit status - Mostra o estado atual dos arquivos + ugit diff - Compara a pasta de trabalho com o último commit + ugit diff - Compara o commit A com o commit B +`); +} + +function scanWorkingDir(root: string, dir: string, fileList: string[] = []): string[] { + if (!fs.existsSync(dir)) return fileList; + const files = fs.readdirSync(dir); + for (const file of files) { + const fullPath = path.join(dir, file); + if (file === '.ugit' || file === '.git' || file === 'node_modules' || file === 'dist' || file === 'package-lock.json') { + continue; + } + const stats = fs.statSync(fullPath); + if (stats.isFile()) { + fileList.push(path.relative(root, fullPath).replace(/\\/g, '/')); + } else if (stats.isDirectory()) { + scanWorkingDir(root, fullPath, fileList); + } + } + return fileList; +} + +function handleDiff(root: string, args: string[]): void { + const headPath = path.join(root, '.ugit', 'HEAD'); + const headSha = fs.existsSync(headPath) ? fs.readFileSync(headPath, 'utf-8').trim() : ''; + + let fileMapA: Record = {}; + let fileMapB: Record = {}; + let labelA = 'a/'; + let labelB = 'b/'; + + if (args.length === 0) { + // Compara o commit HEAD com o diretório de trabalho atual + if (!headSha) { + console.log('Nenhum commit realizado ainda. Nada para comparar.'); + return; + } + const commitObj = ObjectStore.readObject(root, headSha); + const commitMeta = Repository.parseCommit(commitObj.content); + fileMapA = Repository.readTreeRecursive(root, commitMeta.tree); + + // Constrói mapa do diretório de trabalho + const localFiles = scanWorkingDir(root, root); + for (const f of localFiles) { + const content = fs.readFileSync(path.join(root, f)); + const sha1 = ObjectStore.calculateSha1('blob', content); + fileMapB[f] = sha1; + } + labelA = 'HEAD'; + labelB = 'Working Directory'; + } else if (args.length === 2) { + // Compara o commit A com o commit B + const shaA = args[0]; + const shaB = args[1]; + + const commitObjA = ObjectStore.readObject(root, shaA); + const commitMetaA = Repository.parseCommit(commitObjA.content); + fileMapA = Repository.readTreeRecursive(root, commitMetaA.tree); + + const commitObjB = ObjectStore.readObject(root, shaB); + const commitMetaB = Repository.parseCommit(commitObjB.content); + fileMapB = Repository.readTreeRecursive(root, commitMetaB.tree); + + labelA = `commit ${shaA.slice(0, 7)}`; + labelB = `commit ${shaB.slice(0, 7)}`; + } else { + console.error('Argumentos inválidos. Use: ugit diff ou ugit diff '); + process.exit(1); + } + + // Compara os dois mapas de arquivos + const allFiles = new Set([...Object.keys(fileMapA), ...Object.keys(fileMapB)]); + let hasChanges = false; + + for (const file of allFiles) { + const shaA = fileMapA[file]; + const shaB = fileMapB[file]; + + if (shaA === shaB) continue; + + hasChanges = true; + console.log(`\n\x1b[1mdiff --git ${labelA}/${file} ${labelB}/${file}\x1b[0m`); + + let linesA: string[] = []; + let linesB: string[] = []; + + if (shaA) { + const obj = ObjectStore.readObject(root, shaA); + linesA = obj.content.split('\n'); + } + if (shaB) { + let contentB = ''; + if (args.length === 0 && fs.existsSync(path.join(root, file))) { + contentB = fs.readFileSync(path.join(root, file), 'utf-8'); + } else { + const obj = ObjectStore.readObject(root, shaB); + contentB = obj.content; + } + linesB = contentB.split('\n'); + } + + const diffs = myersDiff(linesA, linesB); + for (const diff of diffs) { + if (diff.type === 'added') { + console.log(`\x1b[32m+ ${diff.text}\x1b[0m`); + } else if (diff.type === 'removed') { + console.log(`\x1b[31m- ${diff.text}\x1b[0m`); + } else { + console.log(` ${diff.text}`); + } + } + } + + if (!hasChanges) { + console.log('Nenhuma diferença encontrada.'); + } +} + +function handleStatus(root: string): void { + const index = IndexManager.readIndex(root); + const headPath = path.join(root, '.ugit', 'HEAD'); + const headSha = fs.existsSync(headPath) ? fs.readFileSync(headPath, 'utf-8').trim() : ''; + + let headFiles: Record = {}; + if (headSha) { + const commitObj = ObjectStore.readObject(root, headSha); + const commitMeta = Repository.parseCommit(commitObj.content); + headFiles = Repository.readTreeRecursive(root, commitMeta.tree); + } + + const workingFiles = scanWorkingDir(root, root); + + const staged: string[] = []; + const modifiedNotStaged: string[] = []; + const untracked: string[] = []; + + // 1. Arquivos prontos para commit (Staged) + for (const [file, sha] of Object.entries(index.entries)) { + if (headFiles[file] !== sha) { + staged.push(file); + } + } + + // 2. Arquivos modificados e não adicionados à staging area + // 3. Arquivos não rastreados (Untracked) + for (const file of workingFiles) { + const isStaged = index.entries[file] !== undefined; + const currentContent = fs.readFileSync(path.join(root, file)); + const currentSha = ObjectStore.calculateSha1('blob', currentContent); + + if (isStaged) { + if (index.entries[file] !== currentSha) { + modifiedNotStaged.push(file); + } + } else { + if (headFiles[file]) { + if (headFiles[file] !== currentSha) { + modifiedNotStaged.push(file); + } + } else { + untracked.push(file); + } + } + } + + // Imprime status + console.log(`No branch principal\n`); + + if (staged.length > 0) { + console.log('Modificações prontas para serem commitadas (staged):'); + staged.forEach(f => console.log(` \x1b[32madicionado: ${f}\x1b[0m`)); + console.log(); + } + + if (modifiedNotStaged.length > 0) { + console.log('Modificações não adicionadas à staging area:'); + modifiedNotStaged.forEach(f => console.log(` \x1b[31mmodificado: ${f}\x1b[0m`)); + console.log(); + } + + if (untracked.length > 0) { + console.log('Arquivos não rastreados (untracked):'); + untracked.forEach(f => console.log(` \x1b[35m${f}\x1b[0m`)); + console.log(); + } + + if (staged.length === 0 && modifiedNotStaged.length === 0 && untracked.length === 0) { + console.log('Nada para commitar, pasta de trabalho limpa.'); + } +} + +function run(): void { + const [, , cmd, ...args] = process.argv; + + if (!cmd || cmd === '--help' || cmd === '-h') { + printHelp(); + return; + } + + try { + if (cmd === 'init') { + Repository.init(); + return; + } + + const root = Repository.findRoot(); + + switch (cmd) { + case 'add': + if (args.length === 0) { + console.error('Erro: Especifique o arquivo para adicionar. Ex: ugit add . ou ugit add arquivo.txt'); + process.exit(1); + } + for (const file of args) { + IndexManager.add(root, file); + } + console.log(`Arquivos adicionados com sucesso ao staging.`); + break; + + case 'commit': { + const mIndex = args.indexOf('-m'); + if (mIndex === -1 || !args[mIndex + 1]) { + console.error('Erro: Mensagem de commit obrigatória. Use: ugit commit -m "mensagem"'); + process.exit(1); + } + const message = args[mIndex + 1]; + const author = process.env.USER || process.env.USERNAME || 'HenriqueMC17'; + const sha1 = Repository.commit(root, message, author); + console.log(`[HEAD ${sha1.slice(0, 7)}] ${message}`); + break; + } + + case 'log': { + const headPath = path.join(root, '.ugit', 'HEAD'); + if (!fs.existsSync(headPath)) { + console.log('Nenhum commit realizado ainda.'); + return; + } + let currentSha = fs.readFileSync(headPath, 'utf-8').trim(); + if (!currentSha) { + console.log('Nenhum commit realizado ainda.'); + return; + } + while (currentSha) { + const obj = ObjectStore.readObject(root, currentSha); + const meta = Repository.parseCommit(obj.content); + console.log(`\x1b[33mcommit ${currentSha}\x1b[0m`); + console.log(`Author: ${meta.author}`); + console.log(`Date: ${new Date(meta.timestamp).toString()}`); + console.log(`\n ${meta.message}\n`); + currentSha = meta.parents && meta.parents.length > 0 ? meta.parents[0] : ''; + } + break; + } + + case 'status': + handleStatus(root); + break; + + case 'diff': + handleDiff(root, args); + break; + + default: + console.error(`Comando desconhecido: ${cmd}`); + printHelp(); + process.exit(1); + } + } catch (err: any) { + console.error(err.message); + process.exit(1); + } +} + +run(); diff --git a/HenriqueMC17/src/myers-diff.ts b/HenriqueMC17/src/myers-diff.ts new file mode 100644 index 0000000..5227247 --- /dev/null +++ b/HenriqueMC17/src/myers-diff.ts @@ -0,0 +1,118 @@ +export interface DiffLine { + type: 'added' | 'removed' | 'equal'; + text: string; +} + +/** + * Implementação puramente baseada no Algoritmo de Myers para cálculo de diferença. + * Encontra a sequência de edição mínima (Shortest Edit Script) entre dois conjuntos de linhas. + */ +export function myersDiff(A: string[], B: string[]): DiffLine[] { + const N = A.length; + const M = B.length; + + if (N === 0) { + return B.map(line => ({ type: 'added', text: line })); + } + if (M === 0) { + return A.map(line => ({ type: 'removed', text: line })); + } + + const max = N + M; + const V: Record = { 1: 0 }; + const trace: Record[] = []; + + let x = 0; + let y = 0; + + for (let d = 0; d <= max; d++) { + // Salva o clone de V na pilha de trace + trace.push({ ...V }); + + for (let k = -d; k <= d; k += 2) { + // Determina se viemos de cima (k+1, adição) ou da esquerda (k-1, remoção) + const down = (k === -d || (k !== d && (V[k - 1] ?? -1) < (V[k + 1] ?? -1))); + const prevK = down ? k + 1 : k - 1; + const prevX = V[prevK] ?? 0; + + x = down ? prevX : prevX + 1; + y = x - k; + + // Consome diagonais (elementos iguais) + while (x < N && y < M && A[x] === B[y]) { + x++; + y++; + } + + V[k] = x; + + // Condição de parada: atingiu o fim de ambas as sequências + if (x >= N && y >= M) { + const result: DiffLine[] = []; + let currX = N; + let currY = M; + + // Reconstrói o caminho de trás para frente + for (let step = d; step > 0; step--) { + const stepV = trace[step]; + const currK = currX - currY; + + const wasDown = (currK === -step || (currK !== step && (stepV[currK - 1] ?? -1) < (stepV[currK + 1] ?? -1))); + const parentK = wasDown ? currK + 1 : currK - 1; + + const parentX = stepV[parentK]; + const parentY = parentX - parentK; + + // Adiciona as diagonais (iguais) + while (currX > parentX && currY > parentY) { + result.unshift({ type: 'equal', text: A[currX - 1] }); + currX--; + currY--; + } + + // Adiciona a alteração (adição ou remoção) + if (wasDown) { + result.unshift({ type: 'added', text: B[currY - 1] }); + currY--; + } else { + result.unshift({ type: 'removed', text: A[currX - 1] }); + currX--; + } + } + + // Caso haja diagonais iniciais + while (currX > 0 && currY > 0) { + result.unshift({ type: 'equal', text: A[currX - 1] }); + currX--; + currY--; + } + + return result; + } + } + } + + return fallbackDiff(A, B); +} + +function fallbackDiff(A: string[], B: string[]): DiffLine[] { + const result: DiffLine[] = []; + const setA = new Set(A); + const setB = new Set(B); + + for (const line of A) { + if (setB.has(line)) { + result.push({ type: 'equal', text: line }); + } else { + result.push({ type: 'removed', text: line }); + } + } + + for (const line of B) { + if (!setA.has(line)) { + result.push({ type: 'added', text: line }); + } + } + + return result; +} diff --git a/HenriqueMC17/src/object-store.ts b/HenriqueMC17/src/object-store.ts new file mode 100644 index 0000000..27b7a45 --- /dev/null +++ b/HenriqueMC17/src/object-store.ts @@ -0,0 +1,80 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import * as crypto from 'crypto'; +import * as zlib from 'zlib'; +import { GitObject, ObjectType } from './types'; + +export class ObjectStore { + private static getStoreDir(repoRoot: string): string { + return path.join(repoRoot, '.ugit', 'objects'); + } + + /** + * Calcula o hash SHA-1 do conteúdo formatado. + */ + public static calculateSha1(type: ObjectType, content: string | Buffer): string { + const bufferContent = typeof content === 'string' ? Buffer.from(content, 'utf-8') : content; + const header = Buffer.from(`${type} ${bufferContent.length}\0`, 'utf-8'); + const storeBuffer = Buffer.concat([header, bufferContent]); + + return crypto.createHash('sha1').update(storeBuffer).digest('hex'); + } + + /** + * Grava um objeto no repositório compactado com zlib. + */ + public static writeObject(repoRoot: string, type: ObjectType, content: string | Buffer): string { + const bufferContent = typeof content === 'string' ? Buffer.from(content, 'utf-8') : content; + const header = Buffer.from(`${type} ${bufferContent.length}\0`, 'utf-8'); + const storeBuffer = Buffer.concat([header, bufferContent]); + + const sha1 = crypto.createHash('sha1').update(storeBuffer).digest('hex'); + + const storeDir = this.getStoreDir(repoRoot); + const subDir = path.join(storeDir, sha1.slice(0, 2)); + const filePath = path.join(subDir, sha1.slice(2)); + + if (!fs.existsSync(subDir)) { + fs.mkdirSync(subDir, { recursive: true }); + } + + if (!fs.existsSync(filePath)) { + const compressed = zlib.deflateSync(storeBuffer); + fs.writeFileSync(filePath, compressed); + } + + return sha1; + } + + /** + * Lê e descompacta um objeto do repositório. + */ + public static readObject(repoRoot: string, sha1: string): GitObject { + const storeDir = this.getStoreDir(repoRoot); + const subDir = path.join(storeDir, sha1.slice(0, 2)); + const filePath = path.join(subDir, sha1.slice(2)); + + if (!fs.existsSync(filePath)) { + throw new Error(`Objeto inválido ou inexistente: ${sha1}`); + } + + const compressed = fs.readFileSync(filePath); + const decompressed = zlib.inflateSync(compressed); + + // Encontra o separador nulo \0 para isolar o cabeçalho + const nullIndex = decompressed.indexOf(0); + if (nullIndex === -1) { + throw new Error('Formato de objeto corrompido.'); + } + + const header = decompressed.slice(0, nullIndex).toString('utf-8'); + const content = decompressed.slice(nullIndex + 1).toString('utf-8'); + + const [type, sizeStr] = header.split(' '); + + return { + type: type as ObjectType, + content, + }; + } +} diff --git a/HenriqueMC17/src/repository.ts b/HenriqueMC17/src/repository.ts new file mode 100644 index 0000000..4943e4b --- /dev/null +++ b/HenriqueMC17/src/repository.ts @@ -0,0 +1,186 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import { ObjectStore } from './object-store'; +import { IndexManager } from './index-manager'; +import { CommitMetadata } from './types'; + +export class Repository { + /** + * Procura pela pasta do repositório (.ugit) subindo os diretórios. + */ + public static findRoot(startDir: string = process.cwd()): string { + let current = path.resolve(startDir); + while (true) { + const ugitPath = path.join(current, '.ugit'); + if (fs.existsSync(ugitPath) && fs.statSync(ugitPath).isDirectory()) { + return current; + } + const parent = path.dirname(current); + if (parent === current) { + throw new Error('Erro: Repositório ugit não inicializado. Execute "ugit init" primeiro.'); + } + current = parent; + } + } + + /** + * Inicializa o repositório criando a estrutura do .ugit/ + */ + public static init(targetDir: string = process.cwd()): void { + const ugitPath = path.join(targetDir, '.ugit'); + if (fs.existsSync(ugitPath)) { + throw new Error('Repositório ugit já existe neste diretório.'); + } + + fs.mkdirSync(ugitPath); + fs.mkdirSync(path.join(ugitPath, 'objects')); + fs.writeFileSync(path.join(ugitPath, 'HEAD'), '', 'utf-8'); + + console.log(`Repositório ugit inicializado em ${ugitPath}`); + } + + /** + * Salva o snapshot da staging area em um commit. + */ + public static commit(repoRoot: string, message: string, author: string): string { + const index = IndexManager.readIndex(repoRoot); + if (Object.keys(index.entries).length === 0) { + throw new Error('Nada no commit (staging area vazia). Adicione arquivos com "ugit add".'); + } + + // 1. Constrói recursivamente a árvore do repositório + const rootTreeSha1 = this.writeTreeRecursive(repoRoot, index.entries); + + // 2. Recupera o último commit (HEAD) + const headPath = path.join(repoRoot, '.ugit', 'HEAD'); + const parentSha1 = fs.existsSync(headPath) ? fs.readFileSync(headPath, 'utf-8').trim() : ''; + + // 3. Monta o corpo do commit + const timestamp = Date.now(); + const commitLines: string[] = []; + commitLines.push(`tree ${rootTreeSha1}`); + if (parentSha1) { + commitLines.push(`parent ${parentSha1}`); + } + commitLines.push(`author ${author}`); + commitLines.push(`timestamp ${timestamp}`); + commitLines.push(`message ${message}`); + + const commitContent = commitLines.join('\n'); + + // 4. Salva o objeto do commit + const commitSha1 = ObjectStore.writeObject(repoRoot, 'commit', commitContent); + + // 5. Atualiza a referência HEAD + fs.writeFileSync(headPath, commitSha1, 'utf-8'); + + // 6. Limpa a staging area + IndexManager.writeIndex(repoRoot, { entries: {} }); + + return commitSha1; + } + + /** + * Escreve recursivamente a árvore de arquivos e diretórios em objetos do Git. + */ + private static writeTreeRecursive(repoRoot: string, pathEntries: Record): string { + const blobs: Record = {}; + const subdirs: Record> = {}; + + for (const [relPath, sha1] of Object.entries(pathEntries)) { + const parts = relPath.split('/'); + if (parts.length === 1) { + blobs[parts[0]] = sha1; + } else { + const dir = parts[0]; + const subPath = parts.slice(1).join('/'); + if (!subdirs[dir]) { + subdirs[dir] = {}; + } + subdirs[dir][subPath] = sha1; + } + } + + const treeLines: string[] = []; + + // Adiciona blobs + for (const [name, sha1] of Object.entries(blobs)) { + treeLines.push(`100644 blob ${sha1} ${name}`); + } + + // Adiciona subdiretórios recursivamente + for (const [dirname, subdirEntries] of Object.entries(subdirs)) { + const subdirSha1 = this.writeTreeRecursive(repoRoot, subdirEntries); + treeLines.push(`040000 tree ${subdirSha1} ${dirname}`); + } + + // Ordenação determinística + treeLines.sort((a, b) => { + const nameA = a.split(' ').slice(3).join(' '); + const nameB = b.split(' ').slice(3).join(' '); + return nameA.localeCompare(nameB); + }); + + const treeContent = treeLines.join('\n'); + return ObjectStore.writeObject(repoRoot, 'tree', treeContent); + } + + /** + * Lê recursivamente a árvore de arquivos de um commit e retorna um mapa de arquivos e seus hashes. + */ + public static readTreeRecursive(repoRoot: string, treeSha1: string, currentPrefix = ''): Record { + const fileMap: Record = {}; + const treeObj = ObjectStore.readObject(repoRoot, treeSha1); + + if (treeObj.type !== 'tree') { + throw new Error(`Objeto ${treeSha1} não é uma árvore.`); + } + + if (!treeObj.content.trim()) { + return fileMap; + } + + const lines = treeObj.content.split('\n'); + for (const line of lines) { + if (!line.trim()) continue; + const [mode, type, sha1, ...nameParts] = line.split(' '); + const name = nameParts.join(' '); + const relativePath = currentPrefix ? `${currentPrefix}/${name}` : name; + + if (type === 'blob') { + fileMap[relativePath] = sha1; + } else if (type === 'tree') { + const subdirMap = this.readTreeRecursive(repoRoot, sha1, relativePath); + Object.assign(fileMap, subdirMap); + } + } + + return fileMap; + } + + /** + * Analisa as propriedades estruturadas de um commit do seu formato de texto puro. + */ + public static parseCommit(content: string): CommitMetadata { + const lines = content.split('\n'); + const commit: Partial = { + parents: [], + }; + + for (const line of lines) { + if (line.startsWith('tree ')) { + commit.tree = line.slice(5).trim(); + } else if (line.startsWith('parent ')) { + commit.parents!.push(line.slice(7).trim()); + } else if (line.startsWith('author ')) { + commit.author = line.slice(7).trim(); + } else if (line.startsWith('timestamp ')) { + commit.timestamp = parseInt(line.slice(10).trim()); + } else if (line.startsWith('message ')) { + commit.message = line.slice(8).trim(); + } + } + + return commit as CommitMetadata; + } +} diff --git a/HenriqueMC17/src/types.ts b/HenriqueMC17/src/types.ts new file mode 100644 index 0000000..35a0d0f --- /dev/null +++ b/HenriqueMC17/src/types.ts @@ -0,0 +1,30 @@ +export type ObjectType = 'blob' | 'tree' | 'commit'; + +export interface GitObject { + type: ObjectType; + content: string; +} + +export interface IndexEntry { + path: string; + sha1: string; +} + +export interface Index { + entries: Record; // path -> sha1 +} + +export interface TreeEntry { + mode: string; // e.g., '100644' for file, '040000' for dir + type: 'blob' | 'tree'; + sha1: string; + name: string; +} + +export interface CommitMetadata { + tree: string; + parents: string[]; + author: string; + timestamp: number; + message: string; +} diff --git a/HenriqueMC17/tsconfig.json b/HenriqueMC17/tsconfig.json new file mode 100644 index 0000000..96eb8c4 --- /dev/null +++ b/HenriqueMC17/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "CommonJS", + "moduleResolution": "node", + "outDir": "./dist", + "rootDir": "./src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true + }, + "include": ["src/**/*"] +}