mirror of
https://github.com/tiddly-gittly/TidGi-Desktop.git
synced 2026-03-15 11:11:27 -07:00
* fix: missing return
* feat: showApiKey
* feat: undo commit
* feat: amend commit
* fix: file name quoted in git log
* fix: wikiWorkspaceDefaultValues
* fix: no ai commit message sometimes
* Persist only non-default preferences to storage
Added a utility to store only preferences that differ from defaults, reducing storage size and improving config readability. Updated the setPreferences method to use this utility before saving preferences.
* fix: External Attachment Handling in fs plugin instead of ext-attachment-plugin to handle direct tag update case which won't trigger th-saving-tiddler hook
* feat: api for plugin to create base64 file
* Show all untracked files and recreate Git history window
Updated git status commands to use '-uall' for displaying all untracked files, not just directories. Modified windowService.open calls for Git history to include the { recreate: true } option, ensuring the window is refreshed when opened from various menus.
* fix: handling of external attachments with _canonical_uri
Ensure tiddlers with _canonical_uri are always saved as .tid files, not as binary files, by forcing the .tid extension in FileSystemAdaptor. Update tests to verify this behavior. Also, skip loading files from the external attachments folder in loadWikiTiddlersWithSubWikis to prevent them from being loaded as separate tiddlers.
* Refactor external attachment utilities to module exports
Refactored externalAttachmentUtilities to use ES module exports instead of attaching functions to $tw.utils. Updated imports and mocks accordingly, removed related type definitions from ExtendedUtilities, and cleaned up obsolete meta file.
* disable enableFileSystemWatch to prevent bug for innocent users
* fix: test that requires enableFileSystemWatch use new step set to true
* Fix extension filter usage and sync workspace state after save
Refactored variable naming for extension filters in FileSystemAdaptor to improve clarity and fixed their usage in generateTiddlerFileInfo calls. Removed an unused import in routingUtilities.type.ts. Added a useEffect in useForm to sync workspace state with originalWorkspace after save, ensuring the save button disappears as expected.
* fix: review
* lint
* feat: unify AI commit entry points and add availability check - Unified all AI commit message generation to use syncService.syncWikiIfNeeded() for consistent business logic handling - Added externalAPI.isAIAvailable() method to check if AI provider and model are properly configured - Updated gitService.isAIGenerateBackupTitleEnabled() to use the new availability check - Removed redundant logging code since generateFromAI() automatically logs to database when externalAPIDebug is enabled - Simplified menu item creation logic in menuItems.ts - Ensured AI menu options only appear when both API credentials and free model are configured - Updated documentation to reflect the unified architecture
* Improve AI commit message diff filtering and API checks
Renamed the AI commit message entry points doc for clarity. Enhanced the AI availability check to better handle provider API key requirements, including support for providers that do not require keys. Improved plugin diff filtering to retain small config file diffs while omitting large plugin file contents, optimizing AI token usage.
* Update wiki
* Refactor and enhance Tidgi mini window initialization and sync
Refactors Tidgi mini window startup to use a new initializeTidgiMiniWindow method, improving workspace selection logic and view management. Adds concurrency locks to prevent race conditions during open/close operations. Enhances workspace sync/fixed mode handling, view cleanup, and error logging. Updates interfaces and utilities to support new behaviors and improves robustness of tray icon creation and view realignment.
* Refactor file system sync to use $tw.syncer.syncFromServer()
Introduces FileSystemWatcher to monitor file changes and collect updates for the syncer, replacing direct wiki updates in WatchFileSystemAdaptor. Updates documentation to describe the new syncer-driven architecture, echo prevention, and event handling. WatchFileSystemAdaptor now delegates file change detection and lazy loading to FileSystemWatcher, improving batch change handling and eliminating echo loops.
* Improve logging and cleanup in file system watcher and git ops
Added detailed logging to WatchFileSystemAdaptor and FileSystemWatcher for better traceability during initialization and test stabilization. Introduced a constant for the temporary git index prefix in gitOperations. Removed the unused comparison.ts utility for tiddler comparison. Enhanced comments and logging for AI commit message generation context.
* Improve GitLog i18n test and config refresh logic
Updated gitLog.feature to use only Chinese selectors for actions, revert, and discard buttons, improving i18n test reliability. In FileSystemWatcher, re-fetch workspace config before checking enableFileSystemWatch to ensure latest settings are respected. In useGitLogData, prevent file-change events from overriding commit/undo events to maintain correct auto-selection behavior.
* Improve Git log selection and test stability
Refines auto-selection logic in the Git log window to better handle uncommitted changes, commits, reverts, and undos. Updates the feature test to explicitly verify selection and UI state after each operation, improving reliability. Removes unnecessary config re-fetch in FileSystemWatcher and enhances logging for more accurate DOM update detection.
* Implement workspace config sync via tidgi.config.json
Adds support for syncing workspace configuration to tidgi.config.json in the wiki folder, enabling settings persistence and migration across devices. Introduces new documentation, feature tests, and supporting utilities for config file reading, writing, migration, and validation. Updates step definitions and test helpers to support config sync scenarios, and refactors database config utilities for modularity.
* Improve workspace config handling and sync logic
Enhances workspace lookup in step definitions to check both settings.json and tidgi.config.json, ensuring properties are found even if moved. Updates tidgiConfig write logic to remove the config file if all values are default. Refactors workspace save logic to always write syncable config to tidgi.config.json for all wiki workspaces before removing those fields from settings.json, preventing config loss.
* Update .gitignore
* Update wiki.ts
* Add delay before waiting for git log render after revert
- Add 1 second wait after clearing git-log-data-rendered markers following revert
- This gives UI time to start refreshing before we check for the new marker
- Fixes CI timing issue where revert operation needs more time to trigger UI refresh
* Update test log markers for git log refresh events
Replaces '[test-id-git-log-data-rendered]' with '[test-id-git-log-refreshed]' in gitLog.feature to better reflect UI refresh events after commit and revert actions. Adds a debug log marker '[test-id-git-revert-complete]' in revertCommit for improved test synchronization.
* Fix git revert refresh timing - remove intermediate step and rely on git-log-refreshed
* Add detailed logging to handleRevert for CI debugging
* Fix git log refresh by adding manual triggerRefresh fallback
- Add triggerRefresh function to useGitLogData hook for manual refresh
- Call triggerRefresh in handleCommitSuccess, handleRevertSuccess, and handleUndoSuccess
- This fixes cross-process IPC observable subscription issues where gitStateChange$
notifications from main process may not reach renderer process reliably
- Add detailed logging to handleRevert for CI debugging
* Update index.tsx
999 lines
32 KiB
TypeScript
999 lines
32 KiB
TypeScript
/**
|
||
* Git operations using dugite
|
||
* This module provides git log, checkout, revert functionality
|
||
*/
|
||
import { i18n } from '@services/libs/i18n';
|
||
import { exec as gitExec } from 'dugite';
|
||
import * as fs from 'node:fs/promises';
|
||
import * as os from 'node:os';
|
||
import * as path from 'node:path';
|
||
import { defaultGitInfo } from './defaultGitInfo';
|
||
import type { GitFileStatus, IFileDiffResult, IGitLogOptions, IGitLogResult } from './interface';
|
||
|
||
/** Prefix for temporary Git index directories used during amend/undo operations */
|
||
const TEMP_GIT_INDEX_PREFIX = 'tidgi-git-index-';
|
||
|
||
/**
|
||
* Helper to create git environment variables for commit operations
|
||
* This ensures commits work in environments without git config (like CI)
|
||
*/
|
||
function getGitCommitEnvironment(username: string = defaultGitInfo.gitUserName, email: string = defaultGitInfo.email) {
|
||
return {
|
||
...process.env,
|
||
GIT_AUTHOR_NAME: username,
|
||
GIT_AUTHOR_EMAIL: email,
|
||
GIT_COMMITTER_NAME: username,
|
||
GIT_COMMITTER_EMAIL: email,
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Get git log with pagination
|
||
*/
|
||
export async function getGitLog(repoPath: string, options: IGitLogOptions = {}): Promise<IGitLogResult> {
|
||
const { page = 0, pageSize = 100, searchQuery, searchMode = 'none', filePath, since, until } = options;
|
||
const skip = page * pageSize;
|
||
|
||
// Check for uncommitted changes (only in normal mode)
|
||
const statusResult = await gitExec(['-c', 'core.quotePath=false', 'status', '--porcelain'], repoPath);
|
||
const hasUncommittedChanges = statusResult.stdout.trim().length > 0 && searchMode === 'none';
|
||
|
||
// Build git log command arguments
|
||
const logArguments = [
|
||
'log',
|
||
'--all',
|
||
'--pretty=format:%H|%P|%D|%s|%ci|%an|%ae|%ai',
|
||
'--date=iso',
|
||
`--skip=${skip}`,
|
||
`--max-count=${pageSize}`,
|
||
];
|
||
|
||
// Add search filters based on mode
|
||
if (searchMode === 'message' && searchQuery) {
|
||
logArguments.push(`--grep=${searchQuery}`, '-i'); // -i for case-insensitive
|
||
} else if (searchMode === 'file' && filePath) {
|
||
// File path search - shows commits that modified files matching the pattern
|
||
// Support glob patterns like *.tsx or *pages*
|
||
const pattern = filePath.includes('*') ? filePath : `*${filePath}*`;
|
||
logArguments.push('--', pattern);
|
||
} else if (searchMode === 'dateRange') {
|
||
if (since) {
|
||
logArguments.push(`--since=${since}`);
|
||
}
|
||
if (until) {
|
||
logArguments.push(`--until=${until}`);
|
||
}
|
||
}
|
||
|
||
const result = await gitExec(logArguments, repoPath);
|
||
|
||
if (result.exitCode !== 0) {
|
||
throw new Error(`Git log failed: ${result.stderr}`);
|
||
}
|
||
|
||
// Get current branch
|
||
const branchResult = await gitExec(['rev-parse', '--abbrev-ref', 'HEAD'], repoPath);
|
||
const currentBranch = branchResult.stdout.trim();
|
||
|
||
// Get total count
|
||
const countArguments = ['rev-list', '--all', '--count'];
|
||
if (searchMode === 'message' && searchQuery) {
|
||
countArguments.push(`--grep=${searchQuery}`, '-i'); // -i for case-insensitive
|
||
} else if (searchMode === 'file' && filePath) {
|
||
const pattern = filePath.includes('*') ? filePath : `*${filePath}*`;
|
||
countArguments.push('--', pattern);
|
||
} else if (searchMode === 'dateRange') {
|
||
if (since) {
|
||
countArguments.push(`--since=${since}`);
|
||
}
|
||
if (until) {
|
||
countArguments.push(`--until=${until}`);
|
||
}
|
||
}
|
||
const countResult = await gitExec(countArguments, repoPath);
|
||
const totalCount = Number.parseInt(countResult.stdout.trim(), 10);
|
||
|
||
// Parse log output
|
||
const entries = result.stdout
|
||
.trim()
|
||
.split('\n')
|
||
.filter((line: string) => line.length > 0)
|
||
.map((line: string) => {
|
||
const [hash, parents, references, message, committerDate, authorName, authorEmail, authorDate] = line.split('|');
|
||
|
||
// Extract branch from refs (e.g., "HEAD -> main, origin/main")
|
||
let branch = '';
|
||
if (references) {
|
||
const branchMatch = references.match(/(?:HEAD -> |origin\/)?([^,\s]+)/);
|
||
branch = branchMatch ? branchMatch[1] : '';
|
||
}
|
||
|
||
return {
|
||
hash,
|
||
parents: parents.split(' ').filter((p: string) => p.length > 0),
|
||
branch,
|
||
message,
|
||
committerDate,
|
||
author: {
|
||
name: authorName,
|
||
email: authorEmail || undefined,
|
||
},
|
||
authorDate: authorDate || undefined,
|
||
};
|
||
});
|
||
|
||
// Add uncommitted changes as first entry if any
|
||
if (hasUncommittedChanges && page === 0) {
|
||
const now = new Date().toISOString();
|
||
entries.unshift({
|
||
hash: '',
|
||
parents: [],
|
||
branch: currentBranch,
|
||
message: i18n.t('ContextMenu.UncommittedChanges'),
|
||
committerDate: now,
|
||
author: {
|
||
name: 'Local',
|
||
email: undefined,
|
||
},
|
||
authorDate: now,
|
||
});
|
||
}
|
||
|
||
return {
|
||
entries,
|
||
currentBranch,
|
||
totalCount: totalCount + (hasUncommittedChanges ? 1 : 0),
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Parse git status code to file status
|
||
* Handles both git status --porcelain (two-character codes like "M ", " D", "??")
|
||
* and git diff-tree --name-status (single-character codes like "M", "D", "A")
|
||
*/
|
||
function parseGitStatusCode(statusCode: string): GitFileStatus {
|
||
// Handle single-character status codes from diff-tree
|
||
if (statusCode.length === 1) {
|
||
if (statusCode === 'A') return 'added';
|
||
if (statusCode === 'M') return 'modified';
|
||
if (statusCode === 'D') return 'deleted';
|
||
if (statusCode.startsWith('R')) return 'renamed';
|
||
if (statusCode.startsWith('C')) return 'copied';
|
||
return 'unknown';
|
||
}
|
||
|
||
// Handle two-character status codes from git status --porcelain
|
||
const index = statusCode[0];
|
||
const workTree = statusCode[1];
|
||
|
||
// Check for specific patterns
|
||
if (statusCode === '??') return 'untracked';
|
||
if (index === 'A' || workTree === 'A') return 'added';
|
||
if (index === 'D' || workTree === 'D') return 'deleted';
|
||
if (index === 'R' || workTree === 'R') return 'renamed';
|
||
if (index === 'C' || workTree === 'C') return 'copied';
|
||
if (index === 'M' || workTree === 'M') return 'modified';
|
||
|
||
return 'unknown';
|
||
}
|
||
|
||
/**
|
||
* Get files changed in a specific commit
|
||
* If commitHash is empty, returns uncommitted changes
|
||
*/
|
||
export async function getCommitFiles(repoPath: string, commitHash: string): Promise<Array<import('./interface').IFileWithStatus>> {
|
||
// Handle uncommitted changes
|
||
if (!commitHash || commitHash === '') {
|
||
// Use -uall to show all untracked files, not just directories.
|
||
// This is important for AI commit message generation to see the full context.
|
||
const result = await gitExec(['-c', 'core.quotePath=false', 'status', '--porcelain', '-uall'], repoPath);
|
||
|
||
if (result.exitCode !== 0) {
|
||
throw new Error(`Failed to get uncommitted files: ${result.stderr}`);
|
||
}
|
||
|
||
return result.stdout
|
||
.split(/\r?\n/)
|
||
.map(line => line.trimEnd())
|
||
.filter((line: string) => line.length > 0)
|
||
.map((line: string) => {
|
||
if (line.length <= 3) {
|
||
return { path: line.trim(), status: 'unknown' as const };
|
||
}
|
||
|
||
// Parse git status format: "XY filename"
|
||
// XY is two-letter status code, filename starts at position 3
|
||
const statusCode = line.slice(0, 2);
|
||
const rawPath = line.slice(3);
|
||
|
||
// Handle rename format: "old -> new" – we want the new path
|
||
const renameParts = rawPath.split(' -> ');
|
||
const filePath = renameParts[renameParts.length - 1].trim();
|
||
|
||
return {
|
||
path: filePath,
|
||
status: parseGitStatusCode(statusCode),
|
||
};
|
||
})
|
||
.filter((item) => item.path.length > 0);
|
||
}
|
||
|
||
// For committed changes, use diff-tree with --name-status to get file status
|
||
const result = await gitExec(
|
||
['-c', 'core.quotePath=false', 'diff-tree', '--no-commit-id', '--name-status', '-r', commitHash],
|
||
repoPath,
|
||
);
|
||
|
||
if (result.exitCode !== 0) {
|
||
throw new Error(`Failed to get commit files: ${result.stderr}`);
|
||
}
|
||
|
||
return result.stdout
|
||
.trim()
|
||
.split('\n')
|
||
.filter((line: string) => line.length > 0)
|
||
.map((line: string) => {
|
||
// Format: "STATUS\tFILENAME" or "STATUS\tOLDNAME\tNEWNAME" for renames
|
||
const parts = line.split('\t');
|
||
const statusChar = parts[0];
|
||
const filePath = parts[parts.length - 1]; // Use last part for renames
|
||
|
||
return { path: filePath, status: parseGitStatusCode(statusChar) };
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Get diff for a specific file in a commit
|
||
* @param maxLines - Maximum number of lines to return (default: 500)
|
||
* @param maxChars - Maximum number of characters to return (default: 10000)
|
||
*/
|
||
const IMAGE_EXTENSIONS = ['.png', '.jpg', '.jpeg', '.gif', '.svg', '.webp', '.bmp', '.ico'];
|
||
|
||
export async function getFileDiff(
|
||
repoPath: string,
|
||
commitHash: string,
|
||
filePath: string,
|
||
maxLines = 500,
|
||
maxChars = 10000,
|
||
): Promise<import('./interface').IFileDiffResult> {
|
||
if (!commitHash) {
|
||
const statusResult = await gitExec(['-c', 'core.quotePath=false', 'status', '--porcelain', '--', filePath], repoPath);
|
||
|
||
if (statusResult.exitCode !== 0) {
|
||
throw new Error(`Failed to get status for working tree diff: ${statusResult.stderr}`);
|
||
}
|
||
|
||
const statusLine = statusResult.stdout.trim().split(/\r?\n/).find(Boolean) ?? '';
|
||
const statusCode = statusLine.slice(0, 2);
|
||
const isUntracked = statusCode === '??';
|
||
const isImage = IMAGE_EXTENSIONS.some(extension => filePath.toLowerCase().endsWith(extension));
|
||
|
||
if (isUntracked) {
|
||
if (isImage) {
|
||
return {
|
||
content: `Binary files /dev/null and b/${filePath} differ`,
|
||
isTruncated: false,
|
||
};
|
||
}
|
||
|
||
try {
|
||
const content = await fs.readFile(path.join(repoPath, filePath), 'utf-8');
|
||
const diff = [
|
||
`diff --git a/${filePath} b/${filePath}`,
|
||
'new file mode 100644',
|
||
'--- /dev/null',
|
||
`+++ b/${filePath}`,
|
||
...content.split(/\r?\n/).map(line => `+${line}`),
|
||
].join('\n');
|
||
return truncateDiff(diff, maxLines, maxChars);
|
||
} catch (error) {
|
||
console.error('[getFileDiff] Failed to read untracked file content:', error);
|
||
return {
|
||
content: createBinaryDiffPlaceholder(filePath),
|
||
isTruncated: false,
|
||
};
|
||
}
|
||
}
|
||
|
||
// Check if file is deleted
|
||
const isDeleted = statusCode.includes('D');
|
||
|
||
if (isDeleted) {
|
||
// For deleted files, show the deletion diff
|
||
const result = await gitExec(
|
||
['-c', 'core.quotePath=false', 'diff', 'HEAD', '--', filePath],
|
||
repoPath,
|
||
);
|
||
|
||
if (result.exitCode !== 0) {
|
||
// If diff fails, try to show the file content from HEAD
|
||
const headContent = await gitExec(
|
||
['show', `HEAD:${filePath}`],
|
||
repoPath,
|
||
);
|
||
|
||
if (headContent.exitCode === 0) {
|
||
const diff = [
|
||
`diff --git a/${filePath} b/${filePath}`,
|
||
'deleted file mode 100644',
|
||
`--- a/${filePath}`,
|
||
'+++ /dev/null',
|
||
...headContent.stdout.split(/\r?\n/).map(line => `-${line}`),
|
||
].join('\n');
|
||
return truncateDiff(diff, maxLines, maxChars);
|
||
}
|
||
|
||
throw new Error(`Failed to get diff for deleted file: ${result.stderr}`);
|
||
}
|
||
|
||
return truncateDiff(result.stdout, maxLines, maxChars);
|
||
}
|
||
|
||
const result = await gitExec(
|
||
['-c', 'core.quotePath=false', 'diff', 'HEAD', '--', filePath],
|
||
repoPath,
|
||
);
|
||
|
||
if (result.exitCode !== 0) {
|
||
throw new Error(`Failed to get working tree diff: ${result.stderr}`);
|
||
}
|
||
|
||
if (isImage) {
|
||
const trimmed = result.stdout.trim();
|
||
if (trimmed.length === 0) {
|
||
return {
|
||
content: createBinaryDiffPlaceholder(filePath),
|
||
isTruncated: false,
|
||
};
|
||
}
|
||
}
|
||
|
||
return truncateDiff(result.stdout, maxLines, maxChars);
|
||
}
|
||
|
||
// Use git show with --pretty=format: to get only the diff without commit message
|
||
const result = await gitExec(
|
||
['-c', 'core.quotePath=false', 'show', '--pretty=format:', commitHash, '--', filePath],
|
||
repoPath,
|
||
);
|
||
|
||
if (result.exitCode !== 0) {
|
||
throw new Error(`Failed to get file diff: ${result.stderr}`);
|
||
}
|
||
|
||
return truncateDiff(result.stdout, maxLines, maxChars);
|
||
}
|
||
|
||
/**
|
||
* Get the content of a specific file at a commit
|
||
* @param maxLines - Maximum number of lines to return (default: 500)
|
||
* @param maxChars - Maximum number of characters to return (default: 10000)
|
||
*/
|
||
export async function getFileContent(
|
||
repoPath: string,
|
||
commitHash: string,
|
||
filePath: string,
|
||
maxLines = 500,
|
||
maxChars = 10000,
|
||
): Promise<import('./interface').IFileDiffResult> {
|
||
if (!commitHash) {
|
||
const absolutePath = path.join(repoPath, filePath);
|
||
|
||
try {
|
||
// Try to read the file directly
|
||
const content = await fs.readFile(absolutePath, 'utf-8');
|
||
return truncateContent(content, maxLines, maxChars);
|
||
} catch (error) {
|
||
// File doesn't exist or can't be read - it might be deleted
|
||
// Try to get from HEAD
|
||
try {
|
||
const result = await gitExec(
|
||
['show', `HEAD:${filePath}`],
|
||
repoPath,
|
||
);
|
||
|
||
if (result.exitCode === 0) {
|
||
return truncateContent(result.stdout, maxLines, maxChars);
|
||
}
|
||
} catch {
|
||
// Silently fail and throw main error
|
||
}
|
||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||
throw new Error(`Failed to read file: ${errorMessage}`);
|
||
}
|
||
}
|
||
|
||
// Use git show to get the file content at the specific commit
|
||
const result = await gitExec(
|
||
['show', `${commitHash}:${filePath}`],
|
||
repoPath,
|
||
);
|
||
|
||
if (result.exitCode !== 0) {
|
||
throw new Error(`Failed to get file content: ${result.stderr}`);
|
||
}
|
||
|
||
return truncateContent(result.stdout, maxLines, maxChars);
|
||
}
|
||
|
||
/**
|
||
* Get binary file content (e.g., images) from a commit as base64 data URL
|
||
* Similar to GitHub Desktop's getBlobImage implementation
|
||
* Reference: https://github.com/desktop/desktop/blob/main/app/src/lib/git/show.ts
|
||
*/
|
||
export async function getFileBinaryContent(
|
||
repoPath: string,
|
||
commitHash: string,
|
||
filePath: string,
|
||
): Promise<string> {
|
||
if (!commitHash) {
|
||
try {
|
||
const fullPath = path.join(repoPath, filePath);
|
||
const buffer = await fs.readFile(fullPath);
|
||
return bufferToDataUrl(buffer, filePath);
|
||
} catch (error) {
|
||
throw new Error(`Failed to read binary file from working tree: ${error instanceof Error ? error.message : String(error)}`);
|
||
}
|
||
}
|
||
|
||
// Use gitExec with encoding: 'buffer' to get binary data as Buffer
|
||
// This is the same approach GitHub Desktop uses in getBlobContents
|
||
const result = await gitExec(
|
||
['show', `${commitHash}:${filePath}`],
|
||
repoPath,
|
||
{
|
||
encoding: 'buffer' as const, // dugite 3.x supports this option
|
||
},
|
||
);
|
||
|
||
if (result.exitCode !== 0) {
|
||
const errorMessage = Buffer.isBuffer(result.stderr) ? result.stderr.toString('utf-8') : String(result.stderr);
|
||
throw new Error(`Failed to get binary file content: ${errorMessage}`);
|
||
}
|
||
|
||
// When encoding is 'buffer', stdout is a Buffer (dugite 3.x)
|
||
const buffer = Buffer.isBuffer(result.stdout) ? result.stdout : Buffer.from(String(result.stdout), 'binary');
|
||
return bufferToDataUrl(buffer, filePath);
|
||
}
|
||
|
||
/**
|
||
* Get binary image comparison data for a file change
|
||
* Returns both previous and current versions of the image
|
||
* Reference: GitHub Desktop's getImageDiff implementation
|
||
*/
|
||
export async function getImageComparison(
|
||
repoPath: string,
|
||
commitHash: string,
|
||
filePath: string,
|
||
): Promise<{ previous: string | null; current: string | null }> {
|
||
// Get current version (at this commit)
|
||
let current: string | null = null;
|
||
try {
|
||
current = await getFileBinaryContent(repoPath, commitHash, filePath);
|
||
} catch {
|
||
// File might be deleted in this commit
|
||
}
|
||
|
||
// Get previous version (at parent commit)
|
||
let previous: string | null = null;
|
||
try {
|
||
if (!commitHash) {
|
||
// Compare working tree (current) with HEAD
|
||
try {
|
||
previous = await getFileBinaryContent(repoPath, 'HEAD', filePath);
|
||
} catch {
|
||
// File does not exist in HEAD (newly added)
|
||
}
|
||
} else {
|
||
// Get parent commit hash
|
||
const parentResult = await gitExec(
|
||
['rev-parse', `${commitHash}^`],
|
||
repoPath,
|
||
);
|
||
|
||
if (parentResult.exitCode === 0) {
|
||
const parentHash = parentResult.stdout.trim();
|
||
try {
|
||
previous = await getFileBinaryContent(repoPath, parentHash, filePath);
|
||
} catch {
|
||
// File might be newly added in this commit
|
||
}
|
||
}
|
||
}
|
||
} catch {
|
||
// This is the initial commit, no parent
|
||
}
|
||
|
||
return { previous, current };
|
||
}
|
||
|
||
function bufferToDataUrl(buffer: Buffer, filePath: string): string {
|
||
const base64 = buffer.toString('base64');
|
||
const extension = filePath.toLowerCase().split('.').pop();
|
||
const mimeTypes: Record<string, string> = {
|
||
png: 'image/png',
|
||
jpg: 'image/jpeg',
|
||
jpeg: 'image/jpeg',
|
||
gif: 'image/gif',
|
||
svg: 'image/svg+xml',
|
||
webp: 'image/webp',
|
||
bmp: 'image/bmp',
|
||
ico: 'image/x-icon',
|
||
};
|
||
const mimeType = mimeTypes[extension || ''] || 'application/octet-stream';
|
||
|
||
return `data:${mimeType};base64,${base64}`;
|
||
}
|
||
|
||
function createBinaryDiffPlaceholder(filePath: string): string {
|
||
return `Binary files HEAD and working tree differ (${filePath})`;
|
||
}
|
||
|
||
/**
|
||
* Truncate diff output if it exceeds the limits
|
||
*/
|
||
|
||
function truncateDiff(diff: string, maxLines: number, maxChars: number): IFileDiffResult {
|
||
let truncated = diff;
|
||
let isTruncated = false;
|
||
|
||
// Check character limit first
|
||
if (truncated.length > maxChars) {
|
||
truncated = truncated.slice(0, maxChars);
|
||
isTruncated = true;
|
||
}
|
||
|
||
// Check line limit
|
||
const lines = truncated.split('\n');
|
||
if (lines.length > maxLines) {
|
||
truncated = lines.slice(0, maxLines).join('\n');
|
||
isTruncated = true;
|
||
}
|
||
|
||
return {
|
||
content: truncated,
|
||
isTruncated,
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Truncate content output if it exceeds the limits
|
||
*/
|
||
function truncateContent(content: string, maxLines: number, maxChars: number): IFileDiffResult {
|
||
return truncateDiff(content, maxLines, maxChars);
|
||
}
|
||
|
||
/**
|
||
* Checkout a specific commit
|
||
*/
|
||
export async function checkoutCommit(repoPath: string, commitHash: string): Promise<void> {
|
||
const result = await gitExec(['checkout', commitHash], repoPath);
|
||
|
||
if (result.exitCode !== 0) {
|
||
throw new Error(`Failed to checkout commit: ${result.stderr}`);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Revert a specific commit
|
||
* @param commitMessage - The original commit message to include in the revert message
|
||
*/
|
||
export async function revertCommit(repoPath: string, commitHash: string, commitMessage?: string): Promise<void> {
|
||
const result = await gitExec(['revert', '--no-commit', commitHash], repoPath);
|
||
|
||
if (result.exitCode !== 0) {
|
||
throw new Error(`Failed to revert commit: ${result.stderr}`);
|
||
}
|
||
|
||
// Create revert commit message with the original commit message
|
||
const revertMessage = commitMessage
|
||
? i18n.t('ContextMenu.RevertCommit', { message: commitMessage })
|
||
: `Revert commit ${commitHash}`;
|
||
|
||
// Commit the revert with author/committer identity
|
||
const commitResult = await gitExec(
|
||
['commit', '-m', revertMessage],
|
||
repoPath,
|
||
{
|
||
env: getGitCommitEnvironment(),
|
||
},
|
||
);
|
||
|
||
if (commitResult.exitCode !== 0) {
|
||
throw new Error(`Failed to commit revert: ${commitResult.stderr}`);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Undo a commit by resetting to the previous commit and keeping changes as unstaged
|
||
* This is similar to GitHub Desktop's "Undo" feature
|
||
* Only works on the HEAD commit to prevent unexpected behavior
|
||
*/
|
||
export async function undoCommit(repoPath: string, commitHash: string): Promise<void> {
|
||
// Verify that the provided commitHash is actually the HEAD commit
|
||
const headResult = await gitExec(['rev-parse', 'HEAD'], repoPath);
|
||
if (headResult.exitCode !== 0) {
|
||
throw new Error('Failed to get HEAD commit');
|
||
}
|
||
const headCommit = headResult.stdout.trim();
|
||
|
||
if (commitHash !== headCommit) {
|
||
throw new Error('Can only undo the most recent commit (HEAD). The provided commit is not HEAD.');
|
||
}
|
||
|
||
// Get the parent commit of the current commit
|
||
const parentResult = await gitExec(['rev-parse', `${commitHash}^`], repoPath);
|
||
|
||
if (parentResult.exitCode !== 0) {
|
||
throw new Error('Unable to undo this commit - it may be the first commit in the repository');
|
||
}
|
||
|
||
const parentCommit = parentResult.stdout.trim();
|
||
|
||
// Reset to the parent commit with --mixed flag (keeps changes as unstaged)
|
||
const resetResult = await gitExec(['reset', '--mixed', parentCommit], repoPath);
|
||
|
||
if (resetResult.exitCode !== 0) {
|
||
throw new Error(`Failed to undo commit: ${resetResult.stderr}`);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Discard changes for a specific file (restore from HEAD or delete if untracked)
|
||
*/
|
||
export async function discardFileChanges(repoPath: string, filePath: string): Promise<void> {
|
||
// First check the file status to determine if it's untracked (new file)
|
||
const statusResult = await gitExec(['-c', 'core.quotePath=false', 'status', '--porcelain', '--', filePath], repoPath);
|
||
|
||
if (statusResult.exitCode !== 0) {
|
||
throw new Error(`Failed to check file status: ${statusResult.stderr}`);
|
||
}
|
||
|
||
const statusLine = statusResult.stdout.trim();
|
||
|
||
// If empty, file is not modified
|
||
if (!statusLine) {
|
||
return;
|
||
}
|
||
|
||
// Parse status code (first two characters)
|
||
const statusCode = statusLine.slice(0, 2);
|
||
|
||
// Check if file is untracked (new file not yet added)
|
||
// Status code "??" means untracked
|
||
if (statusCode === '??') {
|
||
// For untracked files, we need to delete them
|
||
const fullPath = path.join(repoPath, filePath);
|
||
try {
|
||
await fs.unlink(fullPath);
|
||
} catch (error) {
|
||
throw new Error(`Failed to delete untracked file: ${error instanceof Error ? error.message : String(error)}`);
|
||
}
|
||
} else {
|
||
// For tracked files, use git checkout to restore from HEAD
|
||
const result = await gitExec(['checkout', 'HEAD', '--', filePath], repoPath);
|
||
|
||
if (result.exitCode !== 0) {
|
||
throw new Error(`Failed to discard changes: ${result.stderr}`);
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Add a file pattern to .gitignore
|
||
*/
|
||
export async function addToGitignore(repoPath: string, pattern: string): Promise<void> {
|
||
const gitignorePath = `${repoPath}/.gitignore`;
|
||
|
||
try {
|
||
// Read existing .gitignore or create new
|
||
let content = '';
|
||
try {
|
||
content = await fs.readFile(gitignorePath, 'utf-8');
|
||
} catch {
|
||
// File doesn't exist, will create new
|
||
}
|
||
|
||
// Check if pattern already exists
|
||
const lines = content.split('\n');
|
||
if (lines.some(line => line.trim() === pattern)) {
|
||
return; // Pattern already exists
|
||
}
|
||
|
||
// Add pattern (ensure file ends with newline)
|
||
const newContent = content.trim() + (content ? '\n' : '') + pattern + '\n';
|
||
await fs.writeFile(gitignorePath, newContent, 'utf-8');
|
||
} catch (error) {
|
||
throw new Error(`Failed to update .gitignore: ${error instanceof Error ? error.message : String(error)}`);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Amend the last commit with a new message
|
||
*/
|
||
export async function amendCommitMessage(repoPath: string, newMessage: string): Promise<void> {
|
||
// Use a temporary index so we amend only the commit message and do not
|
||
// accidentally include user's staged changes or alter their index state.
|
||
const temporaryDirectory = await fs.mkdtemp(path.join(os.tmpdir(), TEMP_GIT_INDEX_PREFIX));
|
||
const temporaryIndex = path.join(temporaryDirectory, 'index');
|
||
try {
|
||
const baseEnvironment = { ...process.env, GIT_INDEX_FILE: temporaryIndex } as NodeJS.ProcessEnv;
|
||
|
||
// Populate the temporary index with HEAD so the tree stays identical
|
||
const readTree = await gitExec(['read-tree', 'HEAD'], repoPath, { env: baseEnvironment });
|
||
if (readTree.exitCode !== 0) {
|
||
throw new Error(`Failed to prepare temporary index from HEAD: ${readTree.stderr}`);
|
||
}
|
||
|
||
const commitEnvironment = { ...getGitCommitEnvironment(), GIT_INDEX_FILE: temporaryIndex } as NodeJS.ProcessEnv;
|
||
const amend = await gitExec(['commit', '--amend', '-m', newMessage], repoPath, { env: commitEnvironment });
|
||
if (amend.exitCode !== 0) {
|
||
throw new Error(`Failed to amend commit message: ${amend.stderr}`);
|
||
}
|
||
} finally {
|
||
await fs.rm(temporaryDirectory, { recursive: true, force: true });
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Get deleted tiddler titles from git history since a specific date
|
||
* This looks for deleted .tid and .meta files and extracts their title field
|
||
* @param repoPath - Path to the git repository
|
||
* @param sinceDate - Date to check for deletions after this time
|
||
* @returns Array of deleted tiddler titles
|
||
*/
|
||
export async function getDeletedTiddlersSinceDate(repoPath: string, sinceDate: Date): Promise<string[]> {
|
||
try {
|
||
// Format date for git log (ISO format)
|
||
const sinceISOString = sinceDate.toISOString();
|
||
|
||
// Get list of deleted files since sinceDate
|
||
// Using git log with --diff-filter=D to show only deletions
|
||
const logResult = await gitExec(
|
||
['-c', 'core.quotePath=false', 'log', `--since=${sinceISOString}`, '--diff-filter=D', '--name-only', '--pretty=format:'],
|
||
repoPath,
|
||
);
|
||
|
||
if (logResult.exitCode !== 0) {
|
||
throw new Error(`Failed to get deleted files: ${logResult.stderr}`);
|
||
}
|
||
|
||
const deletedFiles = logResult.stdout
|
||
.trim()
|
||
.split('\n')
|
||
.filter((line: string) => line.length > 0)
|
||
.filter((file: string) => file.endsWith('.tid') || file.endsWith('.meta'));
|
||
|
||
if (deletedFiles.length === 0) {
|
||
return [];
|
||
}
|
||
|
||
// For each deleted file, get its content from git history to extract the title
|
||
// Parallelize git operations for efficiency (avoid serial git exec calls)
|
||
const deletedTitlePromises = deletedFiles.map(async (file) => {
|
||
try {
|
||
// Get the last commit that had this file (before deletion)
|
||
const revListResult = await gitExec(
|
||
['rev-list', '-n', '1', 'HEAD', '--', file],
|
||
repoPath,
|
||
);
|
||
|
||
if (revListResult.exitCode !== 0 || !revListResult.stdout.trim()) {
|
||
return null;
|
||
}
|
||
|
||
const lastCommitHash = revListResult.stdout.trim();
|
||
|
||
// Get the file content from that commit
|
||
const showResult = await gitExec(
|
||
['show', `${lastCommitHash}:${file}`],
|
||
repoPath,
|
||
);
|
||
|
||
if (showResult.exitCode !== 0) {
|
||
return null;
|
||
}
|
||
|
||
return extractTitleFromTiddlerContent(showResult.stdout);
|
||
} catch (error) {
|
||
console.error(`Error processing deleted file ${file}:`, error);
|
||
return null;
|
||
}
|
||
});
|
||
|
||
const deletedTitles = await Promise.all(deletedTitlePromises);
|
||
|
||
// Remove nulls and duplicates
|
||
return [...new Set(deletedTitles.filter((title): title is string => title !== null))];
|
||
} catch (error) {
|
||
console.error('Error getting deleted tiddlers:', error);
|
||
return [];
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Get tiddler content at a specific point in time from git history
|
||
* This is used for 3-way merge to get the base version
|
||
* @param repoPath - Path to the git repository
|
||
* @param tiddlerTitle - Title of the tiddler
|
||
* @param beforeDate - Get the version that existed before this date
|
||
* @returns Tiddler fields including text, or null if not found
|
||
*/
|
||
export async function getTiddlerAtTime(
|
||
repoPath: string,
|
||
tiddlerTitle: string,
|
||
beforeDate: Date,
|
||
): Promise<{ fields: Record<string, unknown>; text: string } | null> {
|
||
try {
|
||
// Find commits that modified any file before the specified date
|
||
const beforeISOString = beforeDate.toISOString();
|
||
|
||
// First, find all .tid and .meta files that might contain this tiddler
|
||
// We need to search for files because the title might not match the filename
|
||
const logResult = await gitExec(
|
||
['-c', 'core.quotePath=false', 'log', `--before=${beforeISOString}`, '--name-only', '--pretty=format:%H', '--', '*.tid', '*.meta'],
|
||
repoPath,
|
||
);
|
||
|
||
if (logResult.exitCode !== 0) {
|
||
return null;
|
||
}
|
||
|
||
const lines = logResult.stdout.trim().split('\n');
|
||
|
||
// Parse output: commit hash followed by file names
|
||
let currentCommit: string | null = null;
|
||
const filesToCheck: Array<{ commit: string; file: string }> = [];
|
||
|
||
for (const line of lines) {
|
||
if (line.length === 40 && /^[0-9a-f]+$/.test(line)) {
|
||
// This is a commit hash
|
||
currentCommit = line;
|
||
} else if (line.trim().length > 0 && currentCommit) {
|
||
// This is a file name
|
||
const file = line.trim();
|
||
if (file.endsWith('.tid') || file.endsWith('.meta')) {
|
||
filesToCheck.push({ commit: currentCommit, file });
|
||
}
|
||
}
|
||
}
|
||
|
||
// Check each file to find the one with matching title
|
||
// Use Promise.all to check files in parallel instead of sequentially
|
||
const searchPromises = filesToCheck.map(async ({ commit, file }) => {
|
||
try {
|
||
const showResult = await gitExec(
|
||
['show', `${commit}:${file}`],
|
||
repoPath,
|
||
);
|
||
|
||
if (showResult.exitCode === 0) {
|
||
const content = showResult.stdout;
|
||
const parsedTiddler = parseTiddlerContent(content);
|
||
|
||
if (parsedTiddler.fields.title === tiddlerTitle) {
|
||
return parsedTiddler; // Match found
|
||
}
|
||
}
|
||
} catch {
|
||
// Continue checking other files
|
||
}
|
||
return null; // No match in this file
|
||
});
|
||
|
||
// Return the first match found, or null if none match
|
||
const results = await Promise.all(searchPromises);
|
||
return results.find((result): result is { fields: Record<string, unknown>; text: string } => result !== null) ?? null;
|
||
} catch (error) {
|
||
console.error('Error getting tiddler at time:', error);
|
||
return null;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Parse tiddler content (tid or meta file) into fields and text
|
||
*/
|
||
function parseTiddlerContent(content: string): { fields: Record<string, unknown>; text: string } {
|
||
const lines = content.split('\n');
|
||
const fields: Record<string, unknown> = {};
|
||
let textStartIndex = 0;
|
||
|
||
// Parse headers
|
||
for (let index = 0; index < lines.length; index++) {
|
||
const line = lines[index];
|
||
|
||
// Empty line marks end of headers
|
||
if (line.trim() === '') {
|
||
textStartIndex = index + 1;
|
||
break;
|
||
}
|
||
|
||
// Parse field: value format
|
||
const colonIndex = line.indexOf(':');
|
||
if (colonIndex > 0) {
|
||
const fieldName = line.slice(0, colonIndex).trim();
|
||
const fieldValue = line.slice(colonIndex + 1).trim();
|
||
fields[fieldName] = fieldValue;
|
||
}
|
||
}
|
||
|
||
// Get text content (everything after the empty line)
|
||
const text = lines.slice(textStartIndex).join('\n');
|
||
|
||
return { fields, text };
|
||
}
|
||
|
||
/**
|
||
* Extract title field from tiddler content (tid or meta file)
|
||
* Tiddler files have format:
|
||
* ```
|
||
* title: My Tiddler Title
|
||
* tags: [[Tag1]] [[Tag2]]
|
||
* ...
|
||
*
|
||
* Tiddler text content...
|
||
* ```
|
||
*/
|
||
function extractTitleFromTiddlerContent(content: string): string | null {
|
||
const lines = content.split('\n');
|
||
|
||
for (const line of lines) {
|
||
// Look for "title:" field (case-insensitive)
|
||
const titleMatch = line.match(/^title:\s*(.+)$/i);
|
||
if (titleMatch) {
|
||
return titleMatch[1].trim();
|
||
}
|
||
|
||
// Stop at empty line (end of headers)
|
||
if (line.trim() === '') {
|
||
break;
|
||
}
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
/**
|
||
* Get commits that are unpushed to the remote tracking branch
|
||
* Returns a Set of commit hashes that exist locally but not on the remote
|
||
*/
|
||
export async function getUnpushedCommitHashes(repoPath: string): Promise<Set<string>> {
|
||
try {
|
||
// Get the current branch name
|
||
const branchResult = await gitExec(['rev-parse', '--abbrev-ref', 'HEAD'], repoPath);
|
||
if (branchResult.exitCode !== 0) {
|
||
return new Set();
|
||
}
|
||
const currentBranch = branchResult.stdout.trim();
|
||
|
||
// Check if remote tracking branch exists
|
||
const remoteTrackingBranch = `origin/${currentBranch}`;
|
||
const checkRemoteResult = await gitExec(['rev-parse', '--verify', remoteTrackingBranch], repoPath);
|
||
|
||
// If remote tracking branch doesn't exist, no unpushed commits
|
||
if (checkRemoteResult.exitCode !== 0) {
|
||
return new Set();
|
||
}
|
||
|
||
// Get commit hashes that are on local but not on remote: local commits ahead of remote
|
||
const logResult = await gitExec(
|
||
['log', '--pretty=format:%H', `${remoteTrackingBranch}..HEAD`],
|
||
repoPath,
|
||
);
|
||
|
||
if (logResult.exitCode !== 0) {
|
||
return new Set();
|
||
}
|
||
|
||
const hashes = logResult.stdout
|
||
.trim()
|
||
.split('\n')
|
||
.filter((hash: string) => hash.length > 0);
|
||
|
||
return new Set(hashes);
|
||
} catch (error: unknown) {
|
||
// If any error occurs, return empty set (no unpushed commits known)
|
||
console.debug('Failed to get unpushed commits:', error);
|
||
return new Set();
|
||
}
|
||
}
|