Fix git operations from repository subdirectories (#1344)

* Fix git operations from repository subdirectories

* fix bot comments

---------

Co-authored-by: Konstantin Zolin <zolin_ka@vk.com>
This commit is contained in:
kostazol
2026-05-23 21:27:43 +03:00
committed by GitHub
co-authored by Konstantin Zolin
parent 47f72c2e09
commit 3dfefb8ffc
+205 -95
View File
@@ -307,6 +307,63 @@ const normalizeDirectoryPath = (value) => {
return trimmed; return trimmed;
}; };
const toGitPath = (value) => value.replace(/\\/g, '/');
const isInsideOrSameDirectory = (root, target) => {
const relative = path.relative(root, target);
return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative));
};
const resolveGitRepositoryRoot = async (directoryPath, git) => {
const topLevel = await git.raw(['rev-parse', '--show-toplevel']);
const normalizedTopLevel = topLevel.trim();
return path.isAbsolute(normalizedTopLevel)
? path.resolve(normalizedTopLevel)
: path.resolve(directoryPath, normalizedTopLevel);
};
const createRepositoryGitContext = async (directory) => {
const directoryPath = normalizeDirectoryPath(directory);
const directoryGit = await createGit(directoryPath);
const repoRoot = await resolveGitRepositoryRoot(directoryPath, directoryGit);
const git = path.resolve(directoryPath) === repoRoot ? directoryGit : await createGit(repoRoot);
return { directoryPath, directoryGit, repoRoot, git };
};
const resolveGitInternalPath = async (repoRoot, git, gitPath) => {
const resolved = await git.raw(['rev-parse', '--git-path', gitPath]);
return path.resolve(repoRoot, resolved.trim());
};
const resolveGitFileContext = async (directoryPath, git, filePath, repoRootOverride = null) => {
const repoRoot = repoRootOverride || await resolveGitRepositoryRoot(directoryPath, git);
const candidates = Array.from(new Set([
path.resolve(repoRoot, filePath),
path.resolve(directoryPath, filePath),
]));
for (const absolutePath of candidates) {
if (!isInsideOrSameDirectory(repoRoot, absolutePath)) {
continue;
}
const repoPath = toGitPath(path.relative(repoRoot, absolutePath));
const existsInWorktree = await fsp.stat(absolutePath).then((stat) => stat.isFile()).catch(() => false);
const existsInIndex = await git.raw(['cat-file', '-e', `:${repoPath}`]).then(() => true).catch(() => false);
const existsInHead = await git.raw(['cat-file', '-e', `HEAD:${repoPath}`]).then(() => true).catch(() => false);
if (existsInWorktree || existsInIndex || existsInHead) {
return {
absolutePath,
repoPath,
repoRoot,
};
}
}
throw new Error('Invalid file path');
};
const cleanBranchName = (branch) => { const cleanBranchName = (branch) => {
if (!branch) { if (!branch) {
return branch; return branch;
@@ -595,6 +652,21 @@ const runGitCommand = async (cwd, args) => {
} }
}; };
const resolveGitCommitFilePath = async (repoRoot, hash, candidates) => {
for (const candidate of candidates) {
const [originalTreeResult, modifiedTreeResult] = await Promise.all([
runGitCommand(repoRoot, ['ls-tree', '--name-only', `${hash}^`, '--', candidate]),
runGitCommand(repoRoot, ['ls-tree', '--name-only', hash, '--', candidate]),
]);
if ((originalTreeResult.success && originalTreeResult.stdout.trim()) || (modifiedTreeResult.success && modifiedTreeResult.stdout.trim())) {
return candidate;
}
}
throw new Error('Invalid file path');
};
const runGitCommandOrThrow = async (cwd, args, fallbackMessage) => { const runGitCommandOrThrow = async (cwd, args, fallbackMessage) => {
const result = await runGitCommand(cwd, args); const result = await runGitCommand(cwd, args);
if (!result.success) { if (!result.success) {
@@ -1212,11 +1284,11 @@ export async function setLocalIdentity(directory, profile) {
} }
export async function getStatus(directory, options = {}) { export async function getStatus(directory, options = {}) {
const directoryPath = normalizeDirectoryPath(directory);
const git = await createGit(directoryPath);
const lightMode = options.mode === 'light'; const lightMode = options.mode === 'light';
try { try {
const { repoRoot, git } = await createRepositoryGitContext(directory);
// Use -uall to show all untracked files individually, not just directories // Use -uall to show all untracked files individually, not just directories
const status = await git.status(['-uall']); const status = await git.status(['-uall']);
@@ -1285,7 +1357,7 @@ export async function getStatus(directory, options = {}) {
continue; continue;
} }
const absolutePath = path.join(directoryPath, file.path); const absolutePath = path.join(repoRoot, file.path);
try { try {
const stat = await fsp.stat(absolutePath); const stat = await fsp.stat(absolutePath);
@@ -1403,7 +1475,8 @@ export async function getStatus(directory, options = {}) {
const headSha = mergeHead.trim().slice(0, 7); const headSha = mergeHead.trim().slice(0, 7);
// Only set mergeInProgress if we actually have a valid head SHA // Only set mergeInProgress if we actually have a valid head SHA
if (headSha) { if (headSha) {
const mergeMsg = await fsp.readFile(path.join(directoryPath, '.git', 'MERGE_MSG'), 'utf8').catch(() => ''); const mergeMsgPath = await resolveGitInternalPath(repoRoot, git, 'MERGE_MSG').catch(() => '');
const mergeMsg = mergeMsgPath ? await fsp.readFile(mergeMsgPath, 'utf8').catch(() => '') : '';
mergeInProgress = { mergeInProgress = {
head: headSha, head: headSha,
message: mergeMsg.split('\n')[0] || '', message: mergeMsg.split('\n')[0] || '',
@@ -1416,13 +1489,15 @@ export async function getStatus(directory, options = {}) {
try { try {
// Check for rebase in progress (.git/rebase-merge or .git/rebase-apply) // Check for rebase in progress (.git/rebase-merge or .git/rebase-apply)
const rebaseMergeExists = await fsp.stat(path.join(directoryPath, '.git', 'rebase-merge')).then(() => true).catch(() => false); const rebaseMergePath = await resolveGitInternalPath(repoRoot, git, 'rebase-merge').catch(() => '');
const rebaseApplyExists = await fsp.stat(path.join(directoryPath, '.git', 'rebase-apply')).then(() => true).catch(() => false); const rebaseApplyPath = await resolveGitInternalPath(repoRoot, git, 'rebase-apply').catch(() => '');
const rebaseMergeExists = rebaseMergePath ? await fsp.stat(rebaseMergePath).then(() => true).catch(() => false) : false;
const rebaseApplyExists = rebaseApplyPath ? await fsp.stat(rebaseApplyPath).then(() => true).catch(() => false) : false;
if (rebaseMergeExists || rebaseApplyExists) { if (rebaseMergeExists || rebaseApplyExists) {
const rebaseDir = rebaseMergeExists ? 'rebase-merge' : 'rebase-apply'; const rebasePath = rebaseMergeExists ? rebaseMergePath : rebaseApplyPath;
const headName = await fsp.readFile(path.join(directoryPath, '.git', rebaseDir, 'head-name'), 'utf8').catch(() => ''); const headName = await fsp.readFile(path.join(rebasePath, 'head-name'), 'utf8').catch(() => '');
const onto = await fsp.readFile(path.join(directoryPath, '.git', rebaseDir, 'onto'), 'utf8').catch(() => ''); const onto = await fsp.readFile(path.join(rebasePath, 'onto'), 'utf8').catch(() => '');
const headNameTrimmed = headName.trim().replace('refs/heads/', ''); const headNameTrimmed = headName.trim().replace('refs/heads/', '');
const ontoTrimmed = onto.trim().slice(0, 7); const ontoTrimmed = onto.trim().slice(0, 7);
@@ -1462,11 +1537,12 @@ export async function getStatus(directory, options = {}) {
} }
} }
export async function getDiff(directory, { path, staged = false, contextLines = 3 } = {}) { export async function getDiff(directory, { path: filePath, staged = false, contextLines = 3 } = {}) {
const git = await createGit(directory); const { directoryPath, directoryGit, repoRoot, git } = await createRepositoryGitContext(directory);
try { try {
const args = ['diff', '--no-color']; const args = ['diff', '--no-color'];
const fileContext = filePath ? await resolveGitFileContext(directoryPath, directoryGit, filePath, repoRoot) : null;
if (typeof contextLines === 'number' && !Number.isNaN(contextLines)) { if (typeof contextLines === 'number' && !Number.isNaN(contextLines)) {
args.push(`-U${Math.max(0, contextLines)}`); args.push(`-U${Math.max(0, contextLines)}`);
@@ -1476,8 +1552,8 @@ export async function getDiff(directory, { path, staged = false, contextLines =
args.push('--cached'); args.push('--cached');
} }
if (path) { if (fileContext) {
args.push('--', path); args.push('--', fileContext.repoPath);
} }
const diff = await git.raw(args); const diff = await git.raw(args);
@@ -1489,15 +1565,19 @@ export async function getDiff(directory, { path, staged = false, contextLines =
return diff; return diff;
} }
if (!fileContext) {
return diff;
}
try { try {
await git.raw(['ls-files', '--error-unmatch', path]); await git.raw(['ls-files', '--error-unmatch', '--', fileContext.repoPath]);
return diff; return diff;
} catch { } catch {
const noIndexArgs = ['diff', '--no-color']; const noIndexArgs = ['diff', '--no-color'];
if (typeof contextLines === 'number' && !Number.isNaN(contextLines)) { if (typeof contextLines === 'number' && !Number.isNaN(contextLines)) {
noIndexArgs.push(`-U${Math.max(0, contextLines)}`); noIndexArgs.push(`-U${Math.max(0, contextLines)}`);
} }
noIndexArgs.push('--no-index', '--', '/dev/null', path); noIndexArgs.push('--no-index', '--', '/dev/null', fileContext.repoPath);
try { try {
const noIndexDiff = await git.raw(noIndexArgs); const noIndexDiff = await git.raw(noIndexArgs);
return noIndexDiff; return noIndexDiff;
@@ -1515,8 +1595,8 @@ export async function getDiff(directory, { path, staged = false, contextLines =
} }
} }
export async function getRangeDiff(directory, { base, head, path, contextLines = 3 } = {}) { export async function getRangeDiff(directory, { base, head, path: filePath, contextLines = 3 } = {}) {
const git = await createGit(directory); const { directoryPath, directoryGit, repoRoot, git } = await createRepositoryGitContext(directory);
const baseRef = typeof base === 'string' ? base.trim() : ''; const baseRef = typeof base === 'string' ? base.trim() : '';
const headRef = typeof head === 'string' ? head.trim() : ''; const headRef = typeof head === 'string' ? head.trim() : '';
if (!baseRef || !headRef) { if (!baseRef || !headRef) {
@@ -1541,15 +1621,16 @@ export async function getRangeDiff(directory, { base, head, path, contextLines =
args.push(`-U${Math.max(0, contextLines)}`); args.push(`-U${Math.max(0, contextLines)}`);
} }
args.push(`${resolvedBase}...${headRef}`); args.push(`${resolvedBase}...${headRef}`);
if (path) { if (filePath) {
args.push('--', path); const fileContext = await resolveGitFileContext(directoryPath, directoryGit, filePath, repoRoot);
args.push('--', fileContext.repoPath);
} }
const diff = await git.raw(args); const diff = await git.raw(args);
return diff; return diff;
} }
export async function getRangeFiles(directory, { base, head } = {}) { export async function getRangeFiles(directory, { base, head } = {}) {
const git = await createGit(directory); const { git } = await createRepositoryGitContext(directory);
const baseRef = typeof base === 'string' ? base.trim() : ''; const baseRef = typeof base === 'string' ? base.trim() : '';
const headRef = typeof head === 'string' ? head.trim() : ''; const headRef = typeof head === 'string' ? head.trim() : '';
if (!baseRef || !headRef) { if (!baseRef || !headRef) {
@@ -1686,15 +1767,14 @@ export async function getFileDiff(directory, { path: filePath, staged = false }
throw new Error('directory and path are required for getFileDiff'); throw new Error('directory and path are required for getFileDiff');
} }
const directoryPath = normalizeDirectoryPath(directory); const { directoryPath, directoryGit, repoRoot, git } = await createRepositoryGitContext(directory);
const git = await createGit(directoryPath);
const isImage = isImageFile(filePath); const isImage = isImageFile(filePath);
const mimeType = isImage ? getImageMimeType(filePath) : null; const mimeType = isImage ? getImageMimeType(filePath) : null;
const { absolutePath, repoPath } = await resolveGitFileContext(directoryPath, directoryGit, filePath, repoRoot);
if (!isImage) { if (!isImage) {
const absolutePath = path.join(directoryPath, filePath);
const isBinaryBySniff = await looksBinaryBySniff(absolutePath); const isBinaryBySniff = await looksBinaryBySniff(absolutePath);
const isBinary = isBinaryBySniff || (await isBinaryDiff(directoryPath, filePath, staged)); const isBinary = isBinaryBySniff || (await isBinaryDiff(repoRoot, repoPath, staged));
if (isBinary) { if (isBinary) {
return { return {
original: '', original: '',
@@ -1710,8 +1790,8 @@ export async function getFileDiff(directory, { path: filePath, staged = false }
if (isImage) { if (isImage) {
// For images, use git show with raw output and convert to base64 // For images, use git show with raw output and convert to base64
try { try {
const { stdout } = await execFileAsync(getGitBinary(), ['show', `HEAD:${filePath}`], { const { stdout } = await execFileAsync(getGitBinary(), ['show', `HEAD:${repoPath}`], {
cwd: directoryPath, cwd: repoRoot,
encoding: 'buffer', encoding: 'buffer',
windowsHide: true, windowsHide: true,
maxBuffer: 50 * 1024 * 1024, // 50MB max maxBuffer: 50 * 1024 * 1024, // 50MB max
@@ -1723,23 +1803,22 @@ export async function getFileDiff(directory, { path: filePath, staged = false }
original = ''; original = '';
} }
} else { } else {
original = await git.show([`HEAD:${filePath}`]); original = await git.show([`HEAD:${repoPath}`]);
} }
} catch { } catch {
original = ''; original = '';
} }
const fullPath = path.join(directoryPath, filePath);
let modified = ''; let modified = '';
try { try {
const stat = await fsp.stat(fullPath); const stat = await fsp.stat(absolutePath);
if (stat.isFile()) { if (stat.isFile()) {
if (isImage) { if (isImage) {
// For images, read as binary and convert to data URL // For images, read as binary and convert to data URL
const buffer = await fsp.readFile(fullPath); const buffer = await fsp.readFile(absolutePath);
modified = `data:${mimeType};base64,${buffer.toString('base64')}`; modified = `data:${mimeType};base64,${buffer.toString('base64')}`;
} else { } else {
modified = await fsp.readFile(fullPath, 'utf8'); modified = await fsp.readFile(absolutePath, 'utf8');
} }
} }
} catch (error) { } catch (error) {
@@ -1761,26 +1840,23 @@ export async function getFileDiff(directory, { path: filePath, staged = false }
export async function revertFile(directory, filePath) { export async function revertFile(directory, filePath) {
const directoryPath = normalizeDirectoryPath(directory); const directoryPath = normalizeDirectoryPath(directory);
const git = await createGit(directoryPath); const directoryGit = await createGit(directoryPath);
const repoRoot = path.resolve(directoryPath); const repoRoot = await resolveGitRepositoryRoot(directoryPath, directoryGit);
const absoluteTarget = path.resolve(repoRoot, filePath); const { absolutePath, repoPath } = await resolveGitFileContext(directoryPath, directoryGit, filePath, repoRoot);
const git = await createGit(repoRoot);
if (!absoluteTarget.startsWith(repoRoot + path.sep) && absoluteTarget !== repoRoot) {
throw new Error('Invalid file path');
}
const isTracked = await git const isTracked = await git
.raw(['ls-files', '--error-unmatch', filePath]) .raw(['ls-files', '--error-unmatch', '--', repoPath])
.then(() => true) .then(() => true)
.catch(() => false); .catch(() => false);
if (!isTracked) { if (!isTracked) {
try { try {
await git.raw(['clean', '-f', '-d', '--', filePath]); await git.raw(['clean', '-f', '-d', '--', repoPath]);
return; return;
} catch (cleanError) { } catch (cleanError) {
try { try {
await fsp.rm(absoluteTarget, { recursive: true, force: true }); await fsp.rm(absolutePath, { recursive: true, force: true });
return; return;
} catch (fsError) { } catch (fsError) {
if (fsError && typeof fsError === 'object' && fsError.code === 'ENOENT') { if (fsError && typeof fsError === 'object' && fsError.code === 'ENOENT') {
@@ -1793,16 +1869,16 @@ export async function revertFile(directory, filePath) {
} }
try { try {
await git.raw(['restore', '--staged', filePath]); await git.raw(['restore', '--staged', '--', repoPath]);
} catch (error) { } catch (error) {
await git.raw(['reset', 'HEAD', '--', filePath]).catch(() => {}); await git.raw(['reset', 'HEAD', '--', repoPath]).catch(() => {});
} }
try { try {
await git.raw(['restore', filePath]); await git.raw(['restore', '--', repoPath]);
} catch (error) { } catch (error) {
try { try {
await git.raw(['checkout', '--', filePath]); await git.raw(['checkout', '--', repoPath]);
} catch (fallbackError) { } catch (fallbackError) {
console.error('Failed to revert git file:', fallbackError); console.error('Failed to revert git file:', fallbackError);
throw fallbackError; throw fallbackError;
@@ -1826,7 +1902,7 @@ export async function collectDiffs(directory, files = []) {
} }
export async function pull(directory, options = {}) { export async function pull(directory, options = {}) {
const git = await createGit(directory); const { git } = await createRepositoryGitContext(directory);
const pullOptions = options.rebase === true const pullOptions = options.rebase === true
? { ...(options.options && typeof options.options === 'object' && !Array.isArray(options.options) ? options.options : {}), '--rebase': null } ? { ...(options.options && typeof options.options === 'object' && !Array.isArray(options.options) ? options.options : {}), '--rebase': null }
: options.options || {}; : options.options || {};
@@ -1852,7 +1928,7 @@ export async function pull(directory, options = {}) {
} }
export async function listStashes(directory) { export async function listStashes(directory) {
const git = await createGit(directory); const { git } = await createRepositoryGitContext(directory);
const output = await git.raw(['stash', 'list', '--format=%gd%x1f%gs%x1f%cr%x1f%H']); const output = await git.raw(['stash', 'list', '--format=%gd%x1f%gs%x1f%cr%x1f%H']);
return String(output || '') return String(output || '')
.split('\n') .split('\n')
@@ -1866,7 +1942,7 @@ export async function listStashes(directory) {
} }
export async function countStashFiles(directory, refs = []) { export async function countStashFiles(directory, refs = []) {
const git = await createGit(directory); const { git } = await createRepositoryGitContext(directory);
const uniqueRefs = Array.from(new Set((Array.isArray(refs) ? refs : []).map((ref) => String(ref || '').trim()).filter(Boolean))); const uniqueRefs = Array.from(new Set((Array.isArray(refs) ? refs : []).map((ref) => String(ref || '').trim()).filter(Boolean)));
const counts = {}; const counts = {};
const concurrency = 4; const concurrency = 4;
@@ -1889,7 +1965,7 @@ export async function countStashFiles(directory, refs = []) {
return counts; return counts;
} }
export async function stashPush(directory, options = {}) { export async function stashPush(directory, options = {}) {
const git = await createGit(directory); const { git } = await createRepositoryGitContext(directory);
const message = typeof options.message === 'string' && options.message.trim() const message = typeof options.message === 'string' && options.message.trim()
? options.message.trim() ? options.message.trim()
: `OpenChamber stash ${new Date().toISOString()}`; : `OpenChamber stash ${new Date().toISOString()}`;
@@ -1903,14 +1979,14 @@ export async function stashPush(directory, options = {}) {
} }
export async function stashApply(directory, options = {}) { export async function stashApply(directory, options = {}) {
const git = await createGit(directory); const { git } = await createRepositoryGitContext(directory);
const ref = typeof options.ref === 'string' && options.ref.trim() ? options.ref.trim() : 'stash@{0}'; const ref = typeof options.ref === 'string' && options.ref.trim() ? options.ref.trim() : 'stash@{0}';
await git.raw(['stash', 'apply', ref]); await git.raw(['stash', 'apply', ref]);
return { success: true, ref }; return { success: true, ref };
} }
export async function stashDrop(directory, options = {}) { export async function stashDrop(directory, options = {}) {
const git = await createGit(directory); const { git } = await createRepositoryGitContext(directory);
const ref = typeof options.ref === 'string' && options.ref.trim() ? options.ref.trim() : 'stash@{0}'; const ref = typeof options.ref === 'string' && options.ref.trim() ? options.ref.trim() : 'stash@{0}';
await git.raw(['stash', 'drop', ref]); await git.raw(['stash', 'drop', ref]);
return { success: true, ref }; return { success: true, ref };
@@ -1924,7 +2000,7 @@ export async function stashPop(directory, options = {}) {
} }
export async function push(directory, options = {}) { export async function push(directory, options = {}) {
const git = await createGit(directory); const { git } = await createRepositoryGitContext(directory);
const describePushError = (error) => { const describePushError = (error) => {
const fromNestedGit = error?.git && typeof error.git === 'object' const fromNestedGit = error?.git && typeof error.git === 'object'
@@ -2064,7 +2140,7 @@ export async function deleteRemoteBranch(directory, options = {}) {
throw new Error('branch is required to delete remote branch'); throw new Error('branch is required to delete remote branch');
} }
const git = await createGit(directory); const { git } = await createRepositoryGitContext(directory);
const targetBranch = branch.startsWith('refs/heads/') const targetBranch = branch.startsWith('refs/heads/')
? branch.substring('refs/heads/'.length) ? branch.substring('refs/heads/'.length)
: branch; : branch;
@@ -2080,7 +2156,7 @@ export async function deleteRemoteBranch(directory, options = {}) {
} }
export async function fetch(directory, options = {}) { export async function fetch(directory, options = {}) {
const git = await createGit(directory); const { git } = await createRepositoryGitContext(directory);
try { try {
await git.fetch( await git.fetch(
@@ -2097,7 +2173,7 @@ export async function fetch(directory, options = {}) {
} }
export async function commit(directory, message, options = {}) { export async function commit(directory, message, options = {}) {
const git = await createGit(directory); const { directoryPath, directoryGit, repoRoot, git } = await createRepositoryGitContext(directory);
try { try {
const requestedFiles = Array.isArray(options.files) const requestedFiles = Array.isArray(options.files)
@@ -2105,14 +2181,19 @@ export async function commit(directory, message, options = {}) {
.map((value) => String(value || '').trim()) .map((value) => String(value || '').trim())
.filter(Boolean) .filter(Boolean)
: []; : [];
let filesToCommit = requestedFiles; let filesToCommit = [];
if (options.addAll) { if (options.addAll) {
await git.add('.'); await git.add('.');
} else if (requestedFiles.length > 0) { } else if (requestedFiles.length > 0) {
filesToCommit = Array.from(new Set(await Promise.all(requestedFiles.map(async (filePath) => {
const fileContext = await resolveGitFileContext(directoryPath, directoryGit, filePath, repoRoot);
return fileContext.repoPath;
}))));
const status = await git.status(); const status = await git.status();
const fileStatusByPath = new Map(status.files.map((file) => [file.path, file])); const fileStatusByPath = new Map(status.files.map((file) => [file.path, file]));
filesToCommit = requestedFiles.filter((filePath) => fileStatusByPath.has(filePath)); filesToCommit = filesToCommit.filter((filePath) => fileStatusByPath.has(filePath));
if (filesToCommit.length === 0) { if (filesToCommit.length === 0) {
throw new Error('No selected files are available to commit. Refresh git status and try again.'); throw new Error('No selected files are available to commit. Refresh git status and try again.');
@@ -2165,7 +2246,7 @@ export async function commit(directory, message, options = {}) {
} }
export async function getBranches(directory) { export async function getBranches(directory) {
const git = await createGit(directory); const { git } = await createRepositoryGitContext(directory);
try { try {
const result = await git.branch(); const result = await git.branch();
@@ -2226,7 +2307,7 @@ async function filterActiveRemoteBranches(git, remoteBranches) {
} }
export async function createBranch(directory, branchName, options = {}) { export async function createBranch(directory, branchName, options = {}) {
const git = await createGit(directory); const { git } = await createRepositoryGitContext(directory);
try { try {
await git.checkoutBranch(branchName, options.startPoint || 'HEAD'); await git.checkoutBranch(branchName, options.startPoint || 'HEAD');
@@ -2238,7 +2319,7 @@ export async function createBranch(directory, branchName, options = {}) {
} }
export async function checkoutBranch(directory, branchName) { export async function checkoutBranch(directory, branchName) {
const git = await createGit(directory); const { git } = await createRepositoryGitContext(directory);
try { try {
await git.checkout(branchName); await git.checkout(branchName);
@@ -2251,12 +2332,14 @@ export async function checkoutBranch(directory, branchName) {
export async function getWorktrees(directory) { export async function getWorktrees(directory) {
const directoryPath = normalizeDirectoryPath(directory); const directoryPath = normalizeDirectoryPath(directory);
if (!directoryPath || !fs.existsSync(directoryPath) || !fs.existsSync(path.join(directoryPath, '.git'))) { if (!directoryPath || !fs.existsSync(directoryPath)) {
return []; return [];
} }
try { try {
const directoryGit = await createGit(directoryPath);
const repoRoot = await resolveGitRepositoryRoot(directoryPath, directoryGit);
const result = await runGitCommandOrThrow( const result = await runGitCommandOrThrow(
directoryPath, repoRoot,
['worktree', 'list', '--porcelain'], ['worktree', 'list', '--porcelain'],
'Failed to list git worktrees' 'Failed to list git worktrees'
); );
@@ -2689,7 +2772,7 @@ export async function removeWorktree(directory, input = {}) {
} }
export async function deleteBranch(directory, branch, options = {}) { export async function deleteBranch(directory, branch, options = {}) {
const git = await createGit(directory); const { git } = await createRepositoryGitContext(directory);
try { try {
const branchName = branch.startsWith('refs/heads/') const branchName = branch.startsWith('refs/heads/')
@@ -2730,10 +2813,13 @@ export async function resolveBaseRefForLog(from, checkRef) {
} }
export async function getLog(directory, options = {}) { export async function getLog(directory, options = {}) {
const git = await createGit(directory); const { directoryPath, directoryGit, repoRoot, git } = await createRepositoryGitContext(directory);
try { try {
const maxCount = options.maxCount || 50; const maxCount = options.maxCount || 50;
const filePath = options.file
? (await resolveGitFileContext(directoryPath, directoryGit, options.file, repoRoot)).repoPath
: undefined;
// Prefer the local ref; fall back to origin/<from> only when the local ref // Prefer the local ref; fall back to origin/<from> only when the local ref
// cannot be resolved (e.g. user has never checked out the base branch). // cannot be resolved (e.g. user has never checked out the base branch).
@@ -2751,7 +2837,7 @@ export async function getLog(directory, options = {}) {
maxCount, maxCount,
from: resolvedFrom, from: resolvedFrom,
to: options.to, to: options.to,
file: options.file file: filePath
}); });
const logArgs = [ const logArgs = [
@@ -2770,8 +2856,8 @@ export async function getLog(directory, options = {}) {
logArgs.push(options.to); logArgs.push(options.to);
} }
if (options.file) { if (filePath) {
logArgs.push('--', options.file); logArgs.push('--', filePath);
} }
const rawLog = await git.raw(logArgs); const rawLog = await git.raw(logArgs);
@@ -2922,6 +3008,7 @@ export async function canonicalizeWorktreeState(directory) {
const cwd = await canonicalPath(directoryPath); const cwd = await canonicalPath(directoryPath);
const git = await createGit(directoryPath); const git = await createGit(directoryPath);
const repoRoot = await resolveGitRepositoryRoot(directoryPath, git).catch(() => directoryPath);
let worktreeRoot = null; let worktreeRoot = null;
let worktreeStatus = 'ready'; let worktreeStatus = 'ready';
@@ -2962,13 +3049,17 @@ export async function canonicalizeWorktreeState(directory) {
if (status.current && (await git.raw(['rev-parse', '--verify', 'MERGE_HEAD']).then(() => true).catch(() => false))) { if (status.current && (await git.raw(['rev-parse', '--verify', 'MERGE_HEAD']).then(() => true).catch(() => false))) {
attentionReason = 'merge'; attentionReason = 'merge';
} else { } else {
const rebaseMerge = await fsp.stat(path.join(directoryPath, '.git', 'rebase-merge')).then(() => true).catch(() => false); const rebaseMergePath = await resolveGitInternalPath(repoRoot, git, 'rebase-merge').catch(() => '');
const rebaseApply = await fsp.stat(path.join(directoryPath, '.git', 'rebase-apply')).then(() => true).catch(() => false); const rebaseApplyPath = await resolveGitInternalPath(repoRoot, git, 'rebase-apply').catch(() => '');
const rebaseMerge = rebaseMergePath ? await fsp.stat(rebaseMergePath).then(() => true).catch(() => false) : false;
const rebaseApply = rebaseApplyPath ? await fsp.stat(rebaseApplyPath).then(() => true).catch(() => false) : false;
if (rebaseMerge || rebaseApply) { if (rebaseMerge || rebaseApply) {
attentionReason = 'rebase'; attentionReason = 'rebase';
} else if (status.conflicted && status.conflicted.length > 0) { } else if (status.conflicted && status.conflicted.length > 0) {
const cherryPickHead = await fsp.stat(path.join(directoryPath, '.git', 'CHERRY_PICK_HEAD')).then(() => true).catch(() => false); const cherryPickHeadPath = await resolveGitInternalPath(repoRoot, git, 'CHERRY_PICK_HEAD').catch(() => '');
const revertHead = await fsp.stat(path.join(directoryPath, '.git', 'REVERT_HEAD')).then(() => true).catch(() => false); const revertHeadPath = await resolveGitInternalPath(repoRoot, git, 'REVERT_HEAD').catch(() => '');
const cherryPickHead = cherryPickHeadPath ? await fsp.stat(cherryPickHeadPath).then(() => true).catch(() => false) : false;
const revertHead = revertHeadPath ? await fsp.stat(revertHeadPath).then(() => true).catch(() => false) : false;
if (cherryPickHead) attentionReason = 'cherry-pick'; if (cherryPickHead) attentionReason = 'cherry-pick';
else if (revertHead) attentionReason = 'revert'; else if (revertHead) attentionReason = 'revert';
} }
@@ -2990,7 +3081,7 @@ export async function canonicalizeWorktreeState(directory) {
} }
export async function getCommitFiles(directory, commitHash) { export async function getCommitFiles(directory, commitHash) {
const git = await createGit(directory); const { git } = await createRepositoryGitContext(directory);
try { try {
@@ -3071,7 +3162,7 @@ export async function getCommitFiles(directory, commitHash) {
} }
export async function renameBranch(directory, oldName, newName) { export async function renameBranch(directory, oldName, newName) {
const git = await createGit(directory); const { git, repoRoot } = await createRepositoryGitContext(directory);
try { try {
const normalizedOldName = cleanBranchName(String(oldName || '').trim()); const normalizedOldName = cleanBranchName(String(oldName || '').trim());
@@ -3100,12 +3191,12 @@ export async function renameBranch(directory, oldName, newName) {
if (upstream) { if (upstream) {
try { try {
await runGitCommandOrThrow( await runGitCommandOrThrow(
directory, repoRoot,
['branch', `--set-upstream-to=${upstream.full}`, normalizedNewName], ['branch', `--set-upstream-to=${upstream.full}`, normalizedNewName],
`Failed to set upstream to ${upstream.full}` `Failed to set upstream to ${upstream.full}`
); );
} catch { } catch {
await setBranchTrackingFallback(directory, normalizedNewName, upstream); await setBranchTrackingFallback(repoRoot, normalizedNewName, upstream);
} }
} }
} }
@@ -3118,7 +3209,7 @@ export async function renameBranch(directory, oldName, newName) {
} }
export async function getRemotes(directory) { export async function getRemotes(directory) {
const git = await createGit(directory); const { git } = await createRepositoryGitContext(directory);
try { try {
const remotes = await git.getRemotes(true); const remotes = await git.getRemotes(true);
@@ -3146,7 +3237,7 @@ export async function removeRemote(directory, options = {}) {
throw new Error('Cannot remove origin remote'); throw new Error('Cannot remove origin remote');
} }
const git = await createGit(directory); const { git } = await createRepositoryGitContext(directory);
try { try {
await git.removeRemote(remoteName); await git.removeRemote(remoteName);
@@ -3158,7 +3249,7 @@ export async function removeRemote(directory, options = {}) {
} }
export async function rebase(directory, options = {}) { export async function rebase(directory, options = {}) {
const git = await createGit(directory); const { git } = await createRepositoryGitContext(directory);
try { try {
const { onto } = options; const { onto } = options;
@@ -3194,7 +3285,7 @@ export async function rebase(directory, options = {}) {
} }
export async function abortRebase(directory) { export async function abortRebase(directory) {
const git = await createGit(directory); const { git } = await createRepositoryGitContext(directory);
try { try {
await git.rebase(['--abort']); await git.rebase(['--abort']);
@@ -3206,7 +3297,7 @@ export async function abortRebase(directory) {
} }
export async function merge(directory, options = {}) { export async function merge(directory, options = {}) {
const git = await createGit(directory); const { git } = await createRepositoryGitContext(directory);
try { try {
const { branch } = options; const { branch } = options;
@@ -3242,7 +3333,7 @@ export async function merge(directory, options = {}) {
} }
export async function abortMerge(directory) { export async function abortMerge(directory) {
const git = await createGit(directory); const { git } = await createRepositoryGitContext(directory);
try { try {
await git.merge(['--abort']); await git.merge(['--abort']);
@@ -3254,8 +3345,7 @@ export async function abortMerge(directory) {
} }
export async function continueRebase(directory) { export async function continueRebase(directory) {
const directoryPath = normalizeDirectoryPath(directory); const { git } = await createRepositoryGitContext(directory);
const git = await createGit(directoryPath);
try { try {
// Set GIT_EDITOR to prevent editor prompts // Set GIT_EDITOR to prevent editor prompts
@@ -3295,8 +3385,7 @@ export async function continueRebase(directory) {
} }
export async function continueMerge(directory) { export async function continueMerge(directory) {
const directoryPath = normalizeDirectoryPath(directory); const { git } = await createRepositoryGitContext(directory);
const git = await createGit(directoryPath);
try { try {
// Check if there are still unmerged files // Check if there are still unmerged files
@@ -3341,8 +3430,7 @@ export async function continueMerge(directory) {
} }
export async function getConflictDetails(directory) { export async function getConflictDetails(directory) {
const directoryPath = normalizeDirectoryPath(directory); const { repoRoot, git } = await createRepositoryGitContext(directory);
const git = await createGit(directoryPath);
try { try {
// Get git status --porcelain // Get git status --porcelain
@@ -3371,9 +3459,8 @@ export async function getConflictDetails(directory) {
if (mergeHeadExists) { if (mergeHeadExists) {
operation = 'merge'; operation = 'merge';
const mergeHead = await git.raw(['rev-parse', 'MERGE_HEAD']).catch(() => ''); const mergeHead = await git.raw(['rev-parse', 'MERGE_HEAD']).catch(() => '');
const mergeMsg = await fsp const mergeMsgPath = await resolveGitInternalPath(repoRoot, git, 'MERGE_MSG').catch(() => '');
.readFile(path.join(directoryPath, '.git', 'MERGE_MSG'), 'utf8') const mergeMsg = mergeMsgPath ? await fsp.readFile(mergeMsgPath, 'utf8').catch(() => '') : '';
.catch(() => '');
headInfo = `MERGE_HEAD: ${mergeHead.trim()}\n${mergeMsg}`; headInfo = `MERGE_HEAD: ${mergeHead.trim()}\n${mergeMsg}`;
} else { } else {
// Check for REBASE_HEAD (rebase in progress) // Check for REBASE_HEAD (rebase in progress)
@@ -3411,12 +3498,35 @@ export async function getCommitFileDiff(directory, hash, filePath, isBinary) {
return { original: '', modified: '', isBinary: true }; return { original: '', modified: '', isBinary: true };
} }
const directoryPath = normalizeDirectoryPath(directory); const { directoryPath, repoRoot } = await createRepositoryGitContext(directory);
const candidates = Array.from(new Set([
toGitPath(path.relative(repoRoot, path.resolve(repoRoot, filePath))),
toGitPath(path.relative(repoRoot, path.resolve(directoryPath, filePath))),
])).filter((candidate) => candidate && !candidate.startsWith('..') && !path.isAbsolute(candidate));
const [originalResult, modifiedResult] = await Promise.all([ let originalResult = null;
runGitCommand(directoryPath, ['show', `${hash}^:${filePath}`]), let modifiedResult = null;
runGitCommand(directoryPath, ['show', `${hash}:${filePath}`]),
]); for (const candidate of candidates) {
const [candidateOriginalResult, candidateModifiedResult] = await Promise.all([
runGitCommand(repoRoot, ['show', `${hash}^:${candidate}`]),
runGitCommand(repoRoot, ['show', `${hash}:${candidate}`]),
]);
if (candidateOriginalResult.success || candidateModifiedResult.success) {
originalResult = candidateOriginalResult;
modifiedResult = candidateModifiedResult;
break;
}
}
if (!originalResult || !modifiedResult) {
const resolvedPath = await resolveGitCommitFilePath(repoRoot, hash, candidates);
[originalResult, modifiedResult] = await Promise.all([
runGitCommand(repoRoot, ['show', `${hash}^:${resolvedPath}`]),
runGitCommand(repoRoot, ['show', `${hash}:${resolvedPath}`]),
]);
}
const original = originalResult.success ? originalResult.stdout : ''; const original = originalResult.success ? originalResult.stdout : '';
const modified = modifiedResult.success ? modifiedResult.stdout : ''; const modified = modifiedResult.success ? modifiedResult.stdout : '';