File size: 5,858 Bytes
1dbc34b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
/**
 * Service for fetching commit log data from a worktree.
 *
 * Extracts the heavy Git command execution and parsing logic from the
 * commit-log route handler so the handler only validates input,
 * invokes this service, streams lifecycle events, and sends the response.
 *
 * Follows the same approach as branch-commit-log-service: a single
 * `git log --name-only` call with custom separators to fetch both
 * commit metadata and file lists, avoiding N+1 git invocations.
 */

import { execGitCommand } from '../lib/git.js';

// ============================================================================
// Types
// ============================================================================

export interface CommitLogEntry {
  hash: string;
  shortHash: string;
  author: string;
  authorEmail: string;
  date: string;
  subject: string;
  body: string;
  files: string[];
}

export interface CommitLogResult {
  branch: string;
  commits: CommitLogEntry[];
  total: number;
}

// ============================================================================
// Service
// ============================================================================

/**
 * Fetch the commit log for a worktree (HEAD).
 *
 * Runs a single `git log --name-only` invocation plus `git rev-parse`
 * inside the given worktree path and returns a structured result.
 *
 * @param worktreePath - Absolute path to the worktree / repository
 * @param limit        - Maximum number of commits to return (clamped 1-100)
 */
export async function getCommitLog(worktreePath: string, limit: number): Promise<CommitLogResult> {
  // Clamp limit to a reasonable range
  const parsedLimit = Number(limit);
  const commitLimit = Math.min(Math.max(1, Number.isFinite(parsedLimit) ? parsedLimit : 20), 100);

  // Use custom separators to parse both metadata and file lists from
  // a single git log invocation (same approach as branch-commit-log-service).
  //
  // -m causes merge commits to be diffed against each parent so all
  // files touched by the merge are listed (without -m, --name-only
  // produces no file output for merge commits because they have 2+ parents).
  // This means merge commits appear multiple times in the output (once per
  // parent), so we deduplicate by hash below and merge their file lists.
  // We over-fetch (2x the limit) to compensate for -m duplicating merge
  // commit entries, then trim the result to the requested limit.
  // Use ASCII control characters as record separators – these cannot appear in
  // git commit messages, so these delimiters are safe regardless of commit
  // body content.  %x00 and %x01 in git's format string emit literal NUL /
  // SOH bytes respectively.
  //
  // COMMIT_SEP (\x00) – marks the start of each commit record.
  // META_END   (\x01) – separates commit metadata from the --name-only file list.
  //
  // Full per-commit layout emitted by git:
  //   \x00\n<hash>\n<shorthash>\n...\n<subject>\n<body>\x01<files...>
  const COMMIT_SEP = '\x00';
  const META_END = '\x01';
  const fetchLimit = commitLimit * 2;

  const logOutput = await execGitCommand(
    [
      'log',
      `--max-count=${fetchLimit}`,
      '-m',
      '--name-only',
      `--format=%x00%n%H%n%h%n%an%n%ae%n%aI%n%s%n%b%x01`,
    ],
    worktreePath
  );

  // Split output into per-commit blocks and drop the empty first chunk
  // (the output starts with a NUL commit separator).
  const commitBlocks = logOutput.split(COMMIT_SEP).filter((block) => block.trim());

  // Use a Map to deduplicate merge commit entries (which appear once per
  // parent when -m is used) while preserving insertion order.
  const commitMap = new Map<string, CommitLogEntry>();

  for (const block of commitBlocks) {
    const metaEndIdx = block.indexOf(META_END);
    if (metaEndIdx === -1) continue; // malformed block, skip

    // --- Parse metadata (everything before the META_END delimiter) ---
    const metaRaw = block.substring(0, metaEndIdx);
    const metaLines = metaRaw.split('\n');

    // The first line may be empty (newline right after COMMIT_SEP), skip it
    const nonEmptyStart = metaLines.findIndex((l) => l.trim() !== '');
    if (nonEmptyStart === -1) continue;

    const fields = metaLines.slice(nonEmptyStart);
    if (fields.length < 6) continue; // need at least hash..subject

    const hash = fields[0].trim();
    if (!hash) continue; // defensive: skip if hash is empty
    const shortHash = fields[1]?.trim() ?? '';
    const author = fields[2]?.trim() ?? '';
    const authorEmail = fields[3]?.trim() ?? '';
    const date = fields[4]?.trim() ?? '';
    const subject = fields[5]?.trim() ?? '';
    const body = fields.slice(6).join('\n').trim();

    // --- Parse file list (everything after the META_END delimiter) ---
    const filesRaw = block.substring(metaEndIdx + META_END.length);
    const blockFiles = filesRaw
      .trim()
      .split('\n')
      .filter((f) => f.trim());

    // Merge file lists for duplicate entries (merge commits with -m)
    const existing = commitMap.get(hash);
    if (existing) {
      // Add new files to the existing entry's file set
      const fileSet = new Set(existing.files);
      for (const f of blockFiles) fileSet.add(f);
      existing.files = [...fileSet];
    } else {
      commitMap.set(hash, {
        hash,
        shortHash,
        author,
        authorEmail,
        date,
        subject,
        body,
        files: [...new Set(blockFiles)],
      });
    }
  }

  // Trim to the requested limit (we over-fetched to account for -m duplicates)
  const commits = [...commitMap.values()].slice(0, commitLimit);

  // Get current branch name
  const branchOutput = await execGitCommand(['rev-parse', '--abbrev-ref', 'HEAD'], worktreePath);
  const branch = branchOutput.trim();

  return {
    branch,
    commits,
    total: commits.length,
  };
}