summaryrefslogtreecommitdiff
path: root/src/cli/diff.ts
diff options
context:
space:
mode:
Diffstat (limited to 'src/cli/diff.ts')
-rw-r--r--src/cli/diff.ts93
1 files changed, 93 insertions, 0 deletions
diff --git a/src/cli/diff.ts b/src/cli/diff.ts
new file mode 100644
index 0000000..e00c8c0
--- /dev/null
+++ b/src/cli/diff.ts
@@ -0,0 +1,93 @@
+// Diff logic for comparing local files against the last-pulled snapshot
+
+import { join, relative, sep } from 'path'
+import { readFile, readdir, stat } from 'fs/promises'
+import { createHash } from 'crypto'
+
+export type ChangeType = 'added' | 'modified' | 'deleted'
+
+export interface FileChange {
+ path: string // relative path
+ type: ChangeType
+ isText: boolean
+}
+
+// Text extensions matching Overleaf's set (from fileSyncBridge.ts)
+const TEXT_EXTENSIONS = new Set([
+ 'tex', 'latex', 'sty', 'cls', 'bst', 'bib', 'bibtex', 'txt', 'tikz',
+ 'mtx', 'rtex', 'md', 'asy', 'lbx', 'bbx', 'cbx', 'm', 'lco', 'dtx',
+ 'ins', 'ist', 'def', 'clo', 'ldf', 'rmd', 'qmd', 'lua', 'py', 'gv',
+ 'mf', 'yml', 'yaml', 'lhs', 'lean', 'lean4', 'hs', 'mk', 'xmpdata',
+ 'cfg', 'rnw', 'ltx', 'inc',
+ 'fd', 'r', 'sh', 'json', 'xml', 'csv', 'tsv', 'html', 'css', 'js',
+ 'ts', 'c', 'cpp', 'h', 'hpp', 'java', 'rb', 'pl'
+])
+
+const EDITABLE_FILENAMES = new Set(['latexmkrc', '.latexmkrc', 'makefile', 'gnumakefile'])
+
+export function isTextFile(relPath: string): boolean {
+ const name = relPath.split('/').pop()?.toLowerCase() || ''
+ if (EDITABLE_FILENAMES.has(name)) return true
+ const ext = name.split('.').pop() || ''
+ return TEXT_EXTENSIONS.has(ext)
+}
+
+/** Hash file contents with sha256 */
+export async function hashFile(absPath: string): Promise<string> {
+ const data = await readFile(absPath)
+ return createHash('sha256').update(data).digest('hex')
+}
+
+/** Walk a directory, returning relative paths (forward slashes, no leading /) */
+export async function walkDir(dir: string, base?: string): Promise<string[]> {
+ const root = base || dir
+ const results: string[] = []
+ try {
+ const entries = await readdir(dir, { withFileTypes: true })
+ for (const entry of entries) {
+ // Skip the state file and hidden files
+ if (entry.name === '.lattex-cli.json') continue
+ if (entry.name.startsWith('.')) continue
+ const full = join(dir, entry.name)
+ if (entry.isDirectory()) {
+ results.push(...await walkDir(full, root))
+ } else {
+ results.push(relative(root, full).split(sep).join('/'))
+ }
+ }
+ } catch { /* directory may not exist */ }
+ return results
+}
+
+/** Compute diff between local files and stored hashes from last pull */
+export async function computeDiff(
+ dir: string,
+ storedHashes: Record<string, string>,
+ knownPaths: Set<string>
+): Promise<FileChange[]> {
+ const changes: FileChange[] = []
+ const localFiles = await walkDir(dir)
+ const localSet = new Set(localFiles)
+
+ // Check for modified and added files
+ for (const relPath of localFiles) {
+ const absPath = join(dir, relPath)
+ const hash = await hashFile(absPath)
+ const storedHash = storedHashes[relPath]
+
+ if (!storedHash && !knownPaths.has(relPath)) {
+ changes.push({ path: relPath, type: 'added', isText: isTextFile(relPath) })
+ } else if (storedHash && hash !== storedHash) {
+ changes.push({ path: relPath, type: 'modified', isText: isTextFile(relPath) })
+ }
+ }
+
+ // Check for deleted files
+ for (const relPath of Object.keys(storedHashes)) {
+ if (!localSet.has(relPath)) {
+ changes.push({ path: relPath, type: 'deleted', isText: isTextFile(relPath) })
+ }
+ }
+
+ return changes
+}