All files / src/services git.service.js

0% Statements 0/222
0% Branches 0/82
0% Functions 0/19
0% Lines 0/222

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 607 608 609 610 611 612 613 614 615 616 617 618 619 620 621 622 623 624 625 626 627 628 629 630 631 632 633 634 635 636 637 638 639 640 641 642 643 644 645 646 647 648 649 650 651 652 653 654 655 656 657 658 659 660 661 662 663 664 665 666 667 668 669 670 671 672 673 674 675 676 677 678 679 680 681 682 683 684 685 686 687 688 689 690 691 692 693 694 695 696 697 698 699 700 701 702 703 704 705 706 707 708 709 710 711 712 713 714 715 716 717 718 719 720 721 722 723 724 725 726 727 728 729 730 731 732 733 734 735 736 737 738 739 740 741 742 743 744 745 746 747 748 749 750                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                           
/**
 * Git Monitoring Service
 *
 * This service is responsible for monitoring a Git repository for changes
 * and processing new commits. It uses isomorphic-git for Git operations
 * and persists the last processed commit OID to ensure monitoring can
 * resume correctly after server restarts.
 */
 
import * as git from "isomorphic-git";
import fs from "fs";
import config from "../config.js";
import logger from "../utils/logger.js";
import {
  getLastProcessedCommitOid,
  setLastProcessedCommitOid,
  addGitCommit,
  addGitCommitFile,
} from "../db/queries.js";
import IndexingService from "./indexing.service.js";
 
/**
 * Git Monitoring Service
 * Monitors a git repository for changes and processes new commits
 */
export class GitMonitorService {
  /**
   * Creates a new GitMonitorService instance
   * @param {Object} dbClient - The TursoDB client instance
   */
  constructor(dbClient) {
    this.dbClient = dbClient;
    this.fs = fs;
    this.dir = config.PROJECT_PATH;
    this.lastProcessedOid = null;
    this.initialized = false;
    this.isMonitoring = false;
    this.monitorInterval = null;
    this.intervalMs = config.GIT_MONITOR_INTERVAL_MS || 30000; // Default to 30 seconds if not specified
 
    // Initialize the IndexingService
    this.indexingService = new IndexingService(dbClient);
  }
 
  /**
   * Initializes the GitMonitorService by retrieving the last processed commit OID
   * @returns {Promise<void>}
   */
  async initialize() {
    try {
      logger.info("Initializing GitMonitorService...");
 
      // Retrieve the last processed commit OID from the database
      this.lastProcessedOid = await getLastProcessedCommitOid(this.dbClient);
 
      if (this.lastProcessedOid) {
        logger.info(
          `GitMonitorService initialized with last processed commit OID: ${this.lastProcessedOid}`
        );
      } else {
        logger.info(
          "GitMonitorService initialized. No previous commit OID found - will start from current HEAD"
        );
 
        // Get current HEAD to use as starting point
        try {
          const currentHeadOid = await git.resolveRef({
            fs: this.fs,
            dir: this.dir,
            ref: "HEAD",
          });
          logger.info(`Current HEAD OID: ${currentHeadOid}`);
          this.lastProcessedOid = currentHeadOid;
 
          // Store this as the last processed OID
          await this.updateLastProcessedOid(currentHeadOid);
        } catch (gitError) {
          logger.error("Error resolving HEAD reference", {
            error: gitError.message,
            stack: gitError.stack,
          });
        }
      }
 
      this.initialized = true;
      logger.info("GitMonitorService initialization completed");
    } catch (error) {
      logger.error("Error initializing GitMonitorService", {
        error: error.message,
        stack: error.stack,
      });
      throw error;
    }
  }
 
  /**
   * Starts the monitoring polling loop to periodically check for new commits
   * @returns {Promise<void>}
   */
  async startMonitoring() {
    if (!this.initialized) {
      throw new Error(
        "Git monitor service must be initialized before starting monitoring"
      );
    }
 
    if (this.isMonitoring) {
      logger.info("Git monitoring is already active");
      return;
    }
 
    logger.info(
      `Starting Git monitoring with interval of ${this.intervalMs}ms`
    );
 
    // Do an initial check for new commits
    await this.checkForNewCommits();
 
    // Set up the polling interval
    this.monitorInterval = setInterval(async () => {
      try {
        await this.checkForNewCommits();
      } catch (error) {
        logger.error("Error during Git monitoring interval", {
          error: error.message,
          stack: error.stack,
        });
      }
    }, this.intervalMs);
 
    this.isMonitoring = true;
    logger.info("Git monitoring started successfully");
  }
 
  /**
   * Stops the monitoring polling loop
   */
  stopMonitoring() {
    if (!this.isMonitoring) {
      logger.info("Git monitoring is not active");
      return;
    }
 
    logger.info("Stopping Git monitoring");
 
    if (this.monitorInterval) {
      clearInterval(this.monitorInterval);
      this.monitorInterval = null;
    }
 
    this.isMonitoring = false;
    logger.info("Git monitoring stopped successfully");
  }
 
  /**
   * Checks for new Git commits by comparing the latest commit OID with the last processed OID
   * @returns {Promise<boolean>} True if new commits were found, false otherwise
   */
  async checkForNewCommits() {
    if (!this.initialized) {
      throw new Error(
        "Git monitor service must be initialized before checking for commits"
      );
    }
 
    try {
      logger.debug("Checking for new Git commits...");
 
      // Get the current branch name
      const currentBranch = await git.currentBranch({
        fs: this.fs,
        dir: this.dir,
      });
 
      if (!currentBranch) {
        logger.warn(
          "Could not determine current branch, possibly detached HEAD"
        );
        return false;
      }
 
      logger.debug(`Current branch: ${currentBranch}`);
 
      // Get the latest commit OID on the current branch
      const latestOid = await git.resolveRef({
        fs: this.fs,
        dir: this.dir,
        ref: currentBranch,
      });
 
      logger.debug(`Latest commit OID: ${latestOid}`);
      logger.debug(`Last processed OID: ${this.lastProcessedOid}`);
 
      // Compare the latest OID with the last processed OID
      if (latestOid !== this.lastProcessedOid) {
        logger.info(
          `New commits detected. Latest OID: ${latestOid}, Last processed OID: ${this.lastProcessedOid}`
        );
 
        // Extract metadata from new commits
        const newCommits = await this.extractNewCommitsMetadata(latestOid);
        logger.info(`Found ${newCommits.length} new commits`);
 
        // Store the commits in the database
        await this.storeCommitsInDatabase(newCommits);
 
        // Collect all unique changed files from the new commits
        const allChangedFiles = this.collectUniqueChangedFiles(newCommits);
 
        // Trigger the IndexingService to process the changed files
        if (allChangedFiles.length > 0) {
          logger.info(
            `Triggering IndexingService with ${allChangedFiles.length} changed files`
          );
          await this.indexingService.processChanges(allChangedFiles);
        }
 
        // Update the last processed OID to the latest one
        await this.updateLastProcessedOid(latestOid);
 
        return true;
      } else {
        logger.debug("No new commits detected");
        return false;
      }
    } catch (error) {
      logger.error("Error checking for new Git commits", {
        error: error.message,
        stack: error.stack,
      });
      throw error;
    }
  }
 
  /**
   * Collects unique changed files from multiple commits
   * @param {Array<Object>} commits - Array of commit metadata objects
   * @returns {Array<Object>} Array of unique changed file objects
   */
  collectUniqueChangedFiles(commits) {
    try {
      if (!commits || commits.length === 0) {
        return [];
      }
 
      logger.debug(
        `Collecting unique changed files from ${commits.length} commits`
      );
 
      // Map to track the latest status of each file path
      const filePathMap = new Map();
 
      // Process commits in chronological order (oldest first)
      // Since the commits array from extractNewCommitsMetadata is newest first,
      // we need to reverse it to apply changes in the correct order
      const chronologicalCommits = [...commits].reverse();
 
      for (const commit of chronologicalCommits) {
        if (!commit.changedFiles || commit.changedFiles.length === 0) {
          continue;
        }
 
        for (const file of commit.changedFiles) {
          // For renamed files, we track both the old and new paths
          if (file.status === "renamed") {
            // If a file was renamed, remove any tracking of the old path
            // and add tracking for the new path
            filePathMap.delete(file.oldFilePath);
            filePathMap.set(file.newFilePath, {
              filePath: file.newFilePath,
              status: "renamed",
              oldFilePath: file.oldFilePath,
            });
          } else {
            // For added, modified, deleted files, just track the current path and status
            filePathMap.set(file.filePath, {
              filePath: file.filePath,
              status: file.status,
            });
          }
        }
      }
 
      // Convert map values to array
      const uniqueChangedFiles = Array.from(filePathMap.values());
 
      logger.debug(
        `Collected ${uniqueChangedFiles.length} unique changed files`
      );
      return uniqueChangedFiles;
    } catch (error) {
      logger.error("Error collecting unique changed files", {
        error: error.message,
        stack: error.stack,
      });
      return [];
    }
  }
 
  /**
   * Extracts metadata from new commits between the last processed OID and the latest OID
   * @param {string} latestOid - The latest commit OID
   * @returns {Promise<Array<Object>>} Array of commit metadata objects
   */
  async extractNewCommitsMetadata(latestOid) {
    try {
      logger.debug("Extracting metadata from new commits...");
 
      let commits = [];
 
      if (!this.lastProcessedOid) {
        // If no previous OID exists, just get the current HEAD commit
        logger.info("No previous OID, fetching only the latest commit");
        const commitResult = await git.readCommit({
          fs: this.fs,
          dir: this.dir,
          oid: latestOid,
        });
 
        commits = [commitResult];
      } else {
        // Get all commits between the last processed OID and the latest OID
        logger.info(
          `Fetching commits between ${this.lastProcessedOid} and ${latestOid}`
        );
 
        const logCommits = await git.log({
          fs: this.fs,
          dir: this.dir,
          ref: latestOid,
        });
 
        // Process commits until we reach the last processed OID
        for (const commit of logCommits) {
          if (commit.oid === this.lastProcessedOid) {
            break;
          }
          commits.push(commit);
        }
      }
 
      // Extract and format commit metadata with changed files
      const commitsMetadata = [];
 
      for (const commit of commits) {
        const { oid, commit: commitData } = commit;
 
        // Extract the list of changed files for this commit
        const changedFiles = await this.extractChangedFilesFromCommit(commit);
 
        commitsMetadata.push({
          hash: oid,
          authorName: commitData.author.name,
          authorEmail: commitData.author.email,
          date: new Date(commitData.author.timestamp * 1000), // Convert to milliseconds
          message: commitData.message,
          changedFiles: changedFiles,
        });
      }
 
      logger.debug(`Extracted metadata from ${commitsMetadata.length} commits`);
      return commitsMetadata;
    } catch (error) {
      logger.error("Error extracting commit metadata", {
        error: error.message,
        stack: error.stack,
        latestOid,
      });
      throw error;
    }
  }
 
  /**
   * Extracts the list of changed files by comparing a commit's tree with its parent's tree
   * @param {Object} commit - The commit object from git.log or git.readCommit
   * @returns {Promise<Array<Object>>} Array of changed file objects with path and status
   */
  async extractChangedFilesFromCommit(commit) {
    try {
      const { oid, commit: commitData } = commit;
      logger.debug(`Extracting changed files for commit ${oid}`);
 
      // Get the commit's tree
      const currentTreeOid = commitData.tree;
 
      // Check if this commit has parents
      const parentOids = commitData.parent;
 
      // If this is the initial commit (no parent), all files are 'added'
      if (!parentOids || parentOids.length === 0) {
        logger.debug(`Commit ${oid} is the initial commit (no parent)`);
 
        // For initial commit, get all files in the tree as 'added'
        const changedFiles = [];
 
        // Read the tree to get all entries
        const tree = await git.readTree({
          fs: this.fs,
          dir: this.dir,
          oid: currentTreeOid,
        });
 
        // Walk the tree to get all files
        await git.walk({
          fs: this.fs,
          dir: this.dir,
          trees: [git.TREE({ ref: currentTreeOid })],
          map: async (filepath, [entry]) => {
            const type = await entry.type();
 
            // Only process blobs (files), not trees (directories)
            if (type === "blob") {
              changedFiles.push({
                filePath: filepath,
                status: "added",
              });
            }
            return null; // Don't need to return anything for the result
          },
        });
 
        logger.debug(
          `Found ${changedFiles.length} added files in initial commit ${oid}`
        );
        return changedFiles;
      }
 
      // For merge commits, only consider the first parent for now
      // This is a simplification - a more complete implementation would handle multiple parents
      const parentOid = parentOids[0];
 
      if (parentOids.length > 1) {
        logger.info(
          `Commit ${oid} is a merge commit. Only comparing with first parent ${parentOid}`
        );
      }
 
      // Read the parent commit to get its tree
      const parentCommit = await git.readCommit({
        fs: this.fs,
        dir: this.dir,
        oid: parentOid,
      });
 
      const parentTreeOid = parentCommit.commit.tree;
 
      // Now use git.walk to compare the two trees
      let changedFiles = [];
      const addedFiles = [];
      const deletedFiles = [];
      const modifiedFiles = [];
 
      await git.walk({
        fs: this.fs,
        dir: this.dir,
        trees: [
          git.TREE({ ref: parentTreeOid }),
          git.TREE({ ref: currentTreeOid }),
        ],
        map: async (filepath, [parentEntry, currentEntry]) => {
          // File was added (exists in current but not in parent)
          if (!parentEntry && currentEntry) {
            // Store the blob OID for potential rename detection
            const oid = await currentEntry.oid();
            addedFiles.push({
              filePath: filepath,
              status: "added",
              oid: oid,
            });
          }
          // File was deleted (exists in parent but not in current)
          else if (parentEntry && !currentEntry) {
            // Store the blob OID for potential rename detection
            const oid = await parentEntry.oid();
            deletedFiles.push({
              filePath: filepath,
              status: "deleted",
              oid: oid,
            });
          }
          // File exists in both trees, check if it was modified
          else if (parentEntry && currentEntry) {
            // Get the OIDs to compare content
            const parentOid = await parentEntry.oid();
            const currentOid = await currentEntry.oid();
 
            // If OIDs differ, the file was modified
            if (parentOid !== currentOid) {
              modifiedFiles.push({
                filePath: filepath,
                status: "modified",
              });
            }
 
            // If OIDs are the same but modes differ, also consider as modified
            // (e.g., permission changes or file type changes)
            const parentMode = await parentEntry.mode();
            const currentMode = await currentEntry.mode();
 
            if (parentOid === currentOid && parentMode !== currentMode) {
              modifiedFiles.push({
                filePath: filepath,
                status: "modified",
              });
            }
          }
 
          return null; // We don't need to return anything for the result
        },
      });
 
      // Apply rename detection heuristic
      changedFiles = await this.detectRenamesInChangedFiles(
        addedFiles,
        deletedFiles,
        modifiedFiles
      );
 
      logger.debug(
        `Found ${changedFiles.length} changed files in commit ${oid}`
      );
      return changedFiles;
    } catch (error) {
      logger.error(`Error extracting changed files for commit ${commit.oid}`, {
        error: error.message,
        stack: error.stack,
      });
      return []; // Return empty array on error
    }
  }
 
  /**
   * Detects potential renames by comparing content OIDs of added and deleted files
   * @param {Array<Object>} addedFiles - Files that were added in the commit
   * @param {Array<Object>} deletedFiles - Files that were deleted in the commit
   * @param {Array<Object>} modifiedFiles - Files that were modified in the commit
   * @returns {Promise<Array<Object>>} Array of changed files with rename detection
   */
  async detectRenamesInChangedFiles(addedFiles, deletedFiles, modifiedFiles) {
    try {
      // Start with the modified files (these won't be affected by rename detection)
      const changedFiles = [...modifiedFiles];
 
      // Track which files have been processed as renames
      const renamedAddedFiles = new Set();
      const renamedDeletedFiles = new Set();
 
      // Process potential renames by matching OIDs
      for (const deletedFile of deletedFiles) {
        for (const addedFile of addedFiles) {
          // Skip if this added file has already been processed as a rename
          if (renamedAddedFiles.has(addedFile.filePath)) {
            continue;
          }
 
          // If the OIDs match, it's likely a rename
          if (deletedFile.oid === addedFile.oid) {
            // Add a renamed file entry instead of separate add/delete
            changedFiles.push({
              oldFilePath: deletedFile.filePath,
              newFilePath: addedFile.filePath,
              status: "renamed",
              oid: deletedFile.oid,
            });
 
            // Mark these files as processed so they don't appear as separate add/delete
            renamedAddedFiles.add(addedFile.filePath);
            renamedDeletedFiles.add(deletedFile.filePath);
 
            // We found a match for this deleted file, no need to check more
            break;
          }
        }
      }
 
      // Add remaining added files (those not part of renames)
      for (const addedFile of addedFiles) {
        if (!renamedAddedFiles.has(addedFile.filePath)) {
          changedFiles.push({
            filePath: addedFile.filePath,
            status: "added",
          });
        }
      }
 
      // Add remaining deleted files (those not part of renames)
      for (const deletedFile of deletedFiles) {
        if (!renamedDeletedFiles.has(deletedFile.filePath)) {
          changedFiles.push({
            filePath: deletedFile.filePath,
            status: "deleted",
          });
        }
      }
 
      logger.debug(`Processed ${renamedAddedFiles.size} renamed files`);
      return changedFiles;
    } catch (error) {
      logger.error("Error detecting renames in changed files", {
        error: error.message,
        stack: error.stack,
      });
      // In case of error, return the original files without rename detection
      return [...addedFiles, ...deletedFiles, ...modifiedFiles];
    }
  }
 
  /**
   * Stores commit metadata in the database
   * @param {Array<Object>} commits - Array of commit metadata objects
   * @returns {Promise<void>}
   */
  async storeCommitsInDatabase(commits) {
    try {
      logger.info(`Storing ${commits.length} commits in the database...`);
 
      for (const commit of commits) {
        try {
          // Store basic commit information
          await addGitCommit(this.dbClient, {
            commit_hash: commit.hash,
            author_name: commit.authorName,
            author_email: commit.authorEmail,
            commit_date: commit.date,
            message: commit.message,
          });
          logger.debug(`Stored commit ${commit.hash} in database`);
 
          // Store information about changed files
          if (commit.changedFiles && commit.changedFiles.length > 0) {
            logger.debug(
              `Storing ${commit.changedFiles.length} changed files for commit ${commit.hash}`
            );
 
            for (const file of commit.changedFiles) {
              try {
                if (file.status === "renamed") {
                  // Handle renamed files specially
                  await addGitCommitFile(
                    this.dbClient,
                    commit.hash,
                    file.newFilePath,
                    file.status,
                    file.oldFilePath
                  );
                } else {
                  // Handle added, modified, deleted files
                  await addGitCommitFile(
                    this.dbClient,
                    commit.hash,
                    file.filePath,
                    file.status
                  );
                }
              } catch (fileError) {
                logger.error(
                  `Failed to store file information for commit ${commit.hash}`,
                  {
                    error: fileError.message,
                    stack: fileError.stack,
                    filePath:
                      file.status === "renamed"
                        ? file.newFilePath
                        : file.filePath,
                    status: file.status,
                  }
                );
                // Continue with other files even if one fails
              }
            }
          }
 
          // Log the list of changed files for this commit
          if (commit.changedFiles && commit.changedFiles.length > 0) {
            // Log normal file changes
            const normalChanges = commit.changedFiles
              .filter((file) => file.status !== "renamed")
              .map((file) => `${file.filePath} (${file.status})`);
 
            // Log renames with special formatting
            const renameChanges = commit.changedFiles
              .filter((file) => file.status === "renamed")
              .map(
                (file) => `${file.oldFilePath} → ${file.newFilePath} (renamed)`
              );
 
            const allChanges = [...normalChanges, ...renameChanges].join(", ");
 
            logger.debug(
              `Files changed in commit ${commit.hash}: ${allChanges}`
            );
          }
        } catch (commitError) {
          logger.error(`Failed to store commit ${commit.hash} in database`, {
            error: commitError.message,
            stack: commitError.stack,
            commit: commit.hash,
          });
          // Continue with other commits even if one fails
        }
      }
 
      logger.info("Finished storing commits in database");
    } catch (error) {
      logger.error("Error storing commits in database", {
        error: error.message,
        stack: error.stack,
      });
      throw error;
    }
  }
 
  /**
   * Updates the last processed commit OID in the database
   * @param {string} oid - The commit OID to store
   * @returns {Promise<void>}
   */
  async updateLastProcessedOid(oid) {
    try {
      if (!oid) {
        logger.warn(
          "Attempted to update last processed OID with null/undefined value"
        );
        return;
      }
 
      await setLastProcessedCommitOid(this.dbClient, oid);
      this.lastProcessedOid = oid;
      logger.info(`Updated last processed commit OID to: ${oid}`);
    } catch (error) {
      logger.error("Error updating last processed commit OID", {
        error: error.message,
        stack: error.stack,
        oid,
      });
      throw error;
    }
  }
 
  /**
   * Retrieves the current stored last processed commit OID
   * @returns {string|null} The last processed commit OID or null if not available
   */
  getLastProcessedOid() {
    return this.lastProcessedOid;
  }
}
 
export default GitMonitorService;