Skip to content

Commit 6db3126

Browse files
committed
feat: auto remove lock file
tiddly-gittly/TidGi-Desktop#574
1 parent cd2689a commit 6db3126

6 files changed

Lines changed: 206 additions & 16 deletions

File tree

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "git-sync-js",
3-
"version": "2.2.1",
3+
"version": "2.3.0",
44
"description": "JS implementation for Git-Sync, a handy script that backup your notes in a git repo to the remote git services.",
55
"homepage": "https://github.com/linonetwo/git-sync-js",
66
"bugs": {

src/commitAndSync.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { exec } from 'dugite';
22
import { credentialOff, credentialOn } from './credential';
33
import { defaultGitInfo as defaultDefaultGitInfo } from './defaultGitInfo';
44
import { CantSyncGitNotInitializedError, GitPullPushError, SyncParameterMissingError } from './errors';
5-
import { assumeSync, getDefaultBranchName, getGitRepositoryState, getRemoteName, getSyncState, haveLocalChanges } from './inspect';
5+
import { assumeSync, getDefaultBranchName, getGitRepositoryState, getRemoteName, getSyncState, haveLocalChanges, removeGitLockFiles } from './inspect';
66
import { GitStep, IGitUserInfos, ILogger } from './interface';
77
import { commitFiles, continueRebase, fetchRemote, mergeUpstream, pushUpstream } from './sync';
88
import { toGitStringResult } from './utils';
@@ -210,6 +210,12 @@ export async function syncPreflightCheck(configs: {
210210
const { dir, logger, logProgress, logDebug, defaultGitInfo = defaultDefaultGitInfo, userInfo } = configs;
211211
const { gitUserName, email } = userInfo ?? defaultGitInfo;
212212

213+
// Check and remove stale lock files before any git operations
214+
const removedLockFiles = await removeGitLockFiles(dir, logger);
215+
if (removedLockFiles > 0) {
216+
logDebug?.(`Removed ${removedLockFiles} stale git lock file(s) before sync`, GitStep.PrepareSync);
217+
}
218+
213219
const repoStartingState = await getGitRepositoryState(dir, logger);
214220
if (repoStartingState.length === 0 || repoStartingState === '|DIRTY') {
215221
logProgress?.(GitStep.PrepareSync);

src/inspect.ts

Lines changed: 101 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,7 @@ export async function getRemoteUrl(dir: string, remoteName: string): Promise<str
7373
if (result.exitCode === 0 && result.stdout.trim().length > 0) {
7474
return result.stdout.trim();
7575
}
76-
76+
7777
// Fallback: try to get the first remote if the specified one doesn't exist
7878
const remotesResult = toGitStringResult(await exec(['remote'], dir));
7979
if (remotesResult.exitCode === 0) {
@@ -86,7 +86,7 @@ export async function getRemoteUrl(dir: string, remoteName: string): Promise<str
8686
}
8787
}
8888
}
89-
89+
9090
return '';
9191
}
9292

@@ -343,3 +343,102 @@ export async function getRemoteName(dir: string, branch: string): Promise<string
343343
}
344344
return 'origin';
345345
}
346+
347+
/**
348+
* Check if there are stale git lock files and optionally remove them.
349+
* Lock files can be left behind if git operations are interrupted.
350+
*
351+
* @param dir The git repository directory
352+
* @param logger Optional logger for debugging
353+
* @returns Array of lock file paths that were found
354+
*/
355+
export async function checkGitLockFiles(dir: string, logger?: ILogger): Promise<string[]> {
356+
const logDebug = (message: string): unknown =>
357+
logger?.debug(message, {
358+
functionName: 'checkGitLockFiles',
359+
step: GitStep.CheckingLocalGitRepoSanity,
360+
dir,
361+
});
362+
363+
try {
364+
const gitDir = await getGitDirectory(dir);
365+
const lockFiles = [
366+
path.join(gitDir, 'index.lock'),
367+
path.join(gitDir, 'HEAD.lock'),
368+
path.join(gitDir, 'refs', 'heads', '*.lock'),
369+
path.join(gitDir, 'refs', 'remotes', '*.lock'),
370+
];
371+
372+
const foundLockFiles: string[] = [];
373+
374+
for (const lockPattern of lockFiles) {
375+
if (lockPattern.includes('*')) {
376+
// Handle wildcard patterns - check parent directory
377+
const parentDir = path.dirname(lockPattern);
378+
if (await fs.pathExists(parentDir)) {
379+
const files = await fs.readdir(parentDir);
380+
for (const file of files) {
381+
if (file.endsWith('.lock')) {
382+
const fullPath = path.join(parentDir, file);
383+
foundLockFiles.push(fullPath);
384+
logDebug(`Found lock file: ${fullPath}`);
385+
}
386+
}
387+
}
388+
} else {
389+
// Direct path check
390+
if (await fs.pathExists(lockPattern)) {
391+
foundLockFiles.push(lockPattern);
392+
logDebug(`Found lock file: ${lockPattern}`);
393+
}
394+
}
395+
}
396+
397+
return foundLockFiles;
398+
} catch (error) {
399+
logDebug(`Error checking lock files: ${(error as Error).message}`);
400+
return [];
401+
}
402+
}
403+
404+
/**
405+
* Remove stale git lock files that may block git operations.
406+
* This should only be called when you're sure no other git operations are running.
407+
*
408+
* @param dir The git repository directory
409+
* @param logger Optional logger for debugging
410+
* @returns Number of lock files removed
411+
*/
412+
export async function removeGitLockFiles(dir: string, logger?: ILogger): Promise<number> {
413+
const logDebug = (message: string): unknown =>
414+
logger?.debug(message, {
415+
functionName: 'removeGitLockFiles',
416+
step: GitStep.CheckingLocalGitRepoSanity,
417+
dir,
418+
});
419+
const logWarn = (message: string): unknown =>
420+
logger?.warn?.(message, {
421+
functionName: 'removeGitLockFiles',
422+
step: GitStep.CheckingLocalGitRepoSanity,
423+
dir,
424+
});
425+
426+
const lockFiles = await checkGitLockFiles(dir, logger);
427+
let removedCount = 0;
428+
429+
for (const lockFile of lockFiles) {
430+
try {
431+
await fs.remove(lockFile);
432+
removedCount++;
433+
logWarn(`Removed stale lock file: ${lockFile}`);
434+
} catch (error) {
435+
logDebug(`Failed to remove lock file ${lockFile}: ${(error as Error).message}`);
436+
}
437+
}
438+
439+
if (removedCount > 0) {
440+
logWarn(`Removed ${removedCount} stale git lock file(s)`);
441+
}
442+
443+
return removedCount;
444+
}

src/sync.ts

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -37,28 +37,31 @@ export async function commitFiles(
3737
logDebug(`Executing: git add . in ${dir}`, GitStep.AddingFiles);
3838
const addResult = toGitStringResult(await exec(['add', '.'], dir));
3939
logDebug(`git add exitCode: ${addResult.exitCode}, stdout: ${addResult.stdout || '(empty)'}, stderr: ${addResult.stderr || '(empty)'}`, GitStep.AddingFiles);
40-
40+
4141
// Check what's actually in the staging area
4242
const statusResult = toGitStringResult(await exec(['status', '--porcelain'], dir));
4343
logDebug(`git status --porcelain: ${statusResult.stdout || '(empty)'}`, GitStep.AddingFiles);
44-
44+
4545
// Check staged files using git diff --cached
4646
const diffCachedResult = toGitStringResult(await exec(['diff', '--cached', '--name-only'], dir));
4747
const actualStagedFiles = diffCachedResult.stdout.trim().split('\n').filter(f => f.length > 0);
4848
logDebug(`Actual staged files count (from git diff --cached): ${actualStagedFiles.length}`, GitStep.AddingFiles);
4949
if (actualStagedFiles.length > 0) {
50-
logDebug(`Actual staged files: ${actualStagedFiles.slice(0, 10).join(', ')}${actualStagedFiles.length > 10 ? ` ... (${actualStagedFiles.length - 10} more)` : ''}`, GitStep.AddingFiles);
50+
logDebug(
51+
`Actual staged files: ${actualStagedFiles.slice(0, 10).join(', ')}${actualStagedFiles.length > 10 ? ` ... (${actualStagedFiles.length - 10} more)` : ''}`,
52+
GitStep.AddingFiles,
53+
);
5154
} else {
5255
logDebug('No files in staging area after git add!', GitStep.AddingFiles);
5356
}
54-
57+
5558
// find and unStage files that are in the ignore list
5659
if (filesToIgnore.length > 0) {
5760
// Get all tracked files using git ls-files
5861
const lsFilesResult = toGitStringResult(await exec(['ls-files'], dir));
5962
const trackedFiles = lsFilesResult.stdout.trim().split('\n').filter(f => f.length > 0);
6063
logDebug(`Total tracked files count (from git ls-files): ${trackedFiles.length}`, GitStep.AddingFiles);
61-
64+
6265
const stagedFilesToIgnore = filesToIgnore.filter((file) => trackedFiles.includes(file));
6366
logDebug(`Files to ignore count: ${filesToIgnore.length}, staged files to ignore count: ${stagedFilesToIgnore.length}`, GitStep.AddingFiles);
6467
if (stagedFilesToIgnore.length > 0) {
@@ -90,13 +93,13 @@ export async function commitFiles(
9093
),
9194
);
9295
logDebug(`git commit exitCode: ${commitResult.exitCode}, stdout: ${commitResult.stdout || '(empty)'}, stderr: ${commitResult.stderr || '(empty)'}`, GitStep.CommitComplete);
93-
96+
9497
if (commitResult.exitCode === 1 && commitResult.stdout.includes('nothing to commit')) {
9598
logDebug('Git commit reports "nothing to commit" - this is expected if staging area is empty', GitStep.CommitComplete);
9699
} else if (commitResult.exitCode === 128 && commitResult.stderr.includes('Committer identity unknown')) {
97100
logDebug('Git commit failed due to committer identity - this should not happen with env vars set', GitStep.CommitComplete);
98101
}
99-
102+
100103
return commitResult;
101104
}
102105

test/commitAndSync.test.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,12 @@
1-
import { omit } from 'lodash';
21
import { exec } from 'dugite';
2+
import { omit } from 'lodash';
33
import { commitAndSync, ICommitAndSyncOptions } from '../src/commitAndSync';
44
import { defaultGitInfo } from '../src/defaultGitInfo';
55
import { GitPullPushError } from '../src/errors';
66
import { getRemoteUrl, getSyncState, SyncState } from '../src/inspect';
7+
import { toGitStringResult } from '../src/utils';
78
import { creatorGitInfo, dir, exampleToken, upstreamDir } from './constants';
89
import { addSomeFiles } from './utils';
9-
import { toGitStringResult } from '../src/utils';
1010

1111
describe('commitAndSync', () => {
1212
const getCommitAndSyncOptions = (): ICommitAndSyncOptions => ({
@@ -50,15 +50,15 @@ fatal: Authentication failed for 'https://github.com/linonetwo/wiki/'
5050
// Add some files and commit
5151
await addSomeFiles();
5252
await commitAndSync(getCommitAndSyncOptions());
53-
53+
5454
// Verify that both author and committer are set correctly
5555
const logResult = toGitStringResult(
5656
await exec(['log', '--format=%an|%ae|%cn|%ce', '-1'], dir),
5757
);
58-
58+
5959
expect(logResult.exitCode).toBe(0);
6060
const [authorName, authorEmail, committerName, committerEmail] = logResult.stdout.trim().split('|');
61-
61+
6262
// Both author and committer should be set to the same user info
6363
expect(authorName).toBe(defaultGitInfo.gitUserName);
6464
expect(authorEmail).toBe(defaultGitInfo.email);

test/lockFiles.test.ts

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
import fs from 'fs-extra';
2+
import path from 'path';
3+
import { checkGitLockFiles, removeGitLockFiles } from '../src/inspect';
4+
import { dir, gitDirectory } from './constants';
5+
6+
describe('Git lock files', () => {
7+
describe('checkGitLockFiles', () => {
8+
test('returns empty array when no lock files exist', async () => {
9+
const lockFiles = await checkGitLockFiles(dir);
10+
expect(lockFiles).toEqual([]);
11+
});
12+
13+
test('detects index.lock file', async () => {
14+
const lockFilePath = path.join(gitDirectory, 'index.lock');
15+
await fs.writeFile(lockFilePath, 'test lock file');
16+
17+
const lockFiles = await checkGitLockFiles(dir);
18+
expect(lockFiles).toContain(lockFilePath);
19+
20+
// Clean up
21+
await fs.remove(lockFilePath);
22+
});
23+
24+
test('detects HEAD.lock file', async () => {
25+
const lockFilePath = path.join(gitDirectory, 'HEAD.lock');
26+
await fs.writeFile(lockFilePath, 'test lock file');
27+
28+
const lockFiles = await checkGitLockFiles(dir);
29+
expect(lockFiles).toContain(lockFilePath);
30+
31+
// Clean up
32+
await fs.remove(lockFilePath);
33+
});
34+
35+
test('detects lock files in refs/heads', async () => {
36+
const refsHeadsDir = path.join(gitDirectory, 'refs', 'heads');
37+
await fs.mkdirp(refsHeadsDir);
38+
const lockFilePath = path.join(refsHeadsDir, 'master.lock');
39+
await fs.writeFile(lockFilePath, 'test lock file');
40+
41+
const lockFiles = await checkGitLockFiles(dir);
42+
expect(lockFiles).toContain(lockFilePath);
43+
44+
// Clean up
45+
await fs.remove(lockFilePath);
46+
});
47+
});
48+
49+
describe('removeGitLockFiles', () => {
50+
test('removes index.lock file', async () => {
51+
const lockFilePath = path.join(gitDirectory, 'index.lock');
52+
await fs.writeFile(lockFilePath, 'test lock file');
53+
54+
const removedCount = await removeGitLockFiles(dir);
55+
expect(removedCount).toBe(1);
56+
expect(await fs.pathExists(lockFilePath)).toBe(false);
57+
});
58+
59+
test('removes multiple lock files', async () => {
60+
const indexLockPath = path.join(gitDirectory, 'index.lock');
61+
const headLockPath = path.join(gitDirectory, 'HEAD.lock');
62+
await fs.writeFile(indexLockPath, 'test lock file');
63+
await fs.writeFile(headLockPath, 'test lock file');
64+
65+
const removedCount = await removeGitLockFiles(dir);
66+
expect(removedCount).toBe(2);
67+
expect(await fs.pathExists(indexLockPath)).toBe(false);
68+
expect(await fs.pathExists(headLockPath)).toBe(false);
69+
});
70+
71+
test('returns 0 when no lock files exist', async () => {
72+
const removedCount = await removeGitLockFiles(dir);
73+
expect(removedCount).toBe(0);
74+
});
75+
76+
test('handles missing refs directories gracefully', async () => {
77+
// This should not throw an error even if refs/heads doesn't exist
78+
const removedCount = await removeGitLockFiles(dir);
79+
expect(removedCount).toBeGreaterThanOrEqual(0);
80+
});
81+
});
82+
});

0 commit comments

Comments
 (0)