Skill update round trip / roundtrip (false, macos-latest, copy, 1.5.17) (push) Canceled after 0s
Skill update round trip / roundtrip (false, macos-latest, symlink, 1.5.17) (push) Canceled after 0s
Skill update round trip / roundtrip (false, ubuntu-latest, copy, 1.5.17) (push) Canceled after 0s
Skill update round trip / roundtrip (false, ubuntu-latest, symlink, 1.5.17) (push) Canceled after 0s
Skill update round trip / roundtrip (false, ubuntu-latest, symlink, latest) (push) Canceled after 0s
Skill update round trip / roundtrip (false, windows-latest, copy, 1.5.17) (push) Canceled after 0s
Skill update round trip / roundtrip (false, windows-latest, symlink, 1.5.17) (push) Canceled after 0s
Skill update round trip / roundtrip (true, macos-latest, copy, 1.5.17) (push) Canceled after 0s
Skill update round trip / roundtrip (true, macos-latest, symlink, 1.5.17) (push) Canceled after 0s
Skill update round trip / roundtrip (true, ubuntu-latest, copy, 1.5.17) (push) Canceled after 0s
Skill update round trip / roundtrip (true, ubuntu-latest, symlink, 1.5.17) (push) Canceled after 0s
Skill update round trip / roundtrip (true, windows-latest, copy, 1.5.17) (push) Canceled after 0s
Skill update round trip / roundtrip (true, windows-latest, symlink, 1.5.17) (push) Canceled after 0s
307 lines
11 KiB
TypeScript
307 lines
11 KiB
TypeScript
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
|
|
import os from 'node:os'
|
|
import path from 'node:path'
|
|
import { describe, expect, it, vi } from 'vitest'
|
|
import type { Repo } from '../../shared/repo-types'
|
|
import type { Worktree } from '../../shared/worktree/types'
|
|
import { resolveWorkspaceCleanupActivityWorktree } from './workspace-cleanup-activity'
|
|
|
|
const REPO: Repo = {
|
|
id: 'repo-1',
|
|
path: '/repo',
|
|
displayName: 'Repo',
|
|
badgeColor: '#000',
|
|
addedAt: 1
|
|
}
|
|
|
|
function makeWorktree(overrides: Partial<Worktree> = {}): Worktree {
|
|
return {
|
|
id: 'repo-1::/repo-feature',
|
|
repoId: 'repo-1',
|
|
path: '/repo-feature',
|
|
head: 'abc123',
|
|
branch: 'refs/heads/feature',
|
|
isBare: false,
|
|
isMainWorktree: false,
|
|
displayName: 'feature',
|
|
comment: '',
|
|
linkedIssue: null,
|
|
linkedPR: null,
|
|
linkedLinearIssue: null,
|
|
linkedLinearIssueWorkspaceId: null,
|
|
linkedLinearIssueOrganizationUrlKey: null,
|
|
linkedGitLabMR: null,
|
|
linkedGitLabIssue: null,
|
|
linkedBitbucketPR: null,
|
|
linkedAzureDevOpsPR: null,
|
|
linkedGiteaPR: null,
|
|
isArchived: false,
|
|
isUnread: false,
|
|
isPinned: false,
|
|
sortOrder: 0,
|
|
lastActivityAt: 0,
|
|
workspaceStatus: 'in-progress',
|
|
...overrides
|
|
}
|
|
}
|
|
|
|
describe('resolveWorkspaceCleanupActivityWorktree', () => {
|
|
it('uses local worktree filesystem metadata when persisted activity is missing', async () => {
|
|
const statPath = vi.fn(async (targetPath: string) => ({
|
|
mtimeMs: targetPath.endsWith('.git') ? 20_000 : 10_000
|
|
}))
|
|
|
|
const worktree = await resolveWorkspaceCleanupActivityWorktree(REPO, makeWorktree(), statPath)
|
|
|
|
expect(statPath).toHaveBeenCalledWith('/repo-feature')
|
|
expect(statPath).toHaveBeenCalledWith(path.join('/repo-feature', '.git'))
|
|
expect(worktree.lastActivityAt).toBe(20_000)
|
|
})
|
|
|
|
it('uses linked worktree commit metadata when the .git pointer is stale', async () => {
|
|
const gitDirPath = path.join('/repo', '.git', 'worktrees', 'repo-feature')
|
|
const gitDirHeadPath = path.join(gitDirPath, 'HEAD')
|
|
const commitEditMsgPath = path.join(gitDirPath, 'COMMIT_EDITMSG')
|
|
const origHeadPath = path.join(gitDirPath, 'ORIG_HEAD')
|
|
const statPath = vi.fn(async (targetPath: string) => {
|
|
const mtimes: Record<string, number> = {
|
|
'/repo-feature': 10_000,
|
|
[path.join('/repo-feature', '.git')]: 20_000,
|
|
[gitDirHeadPath]: 60_000,
|
|
[origHeadPath]: 65_000,
|
|
[commitEditMsgPath]: 70_000
|
|
}
|
|
return { mtimeMs: mtimes[targetPath] ?? 0 }
|
|
})
|
|
const readTextFile = vi.fn(async () => `gitdir: ${gitDirPath}\n`)
|
|
|
|
const worktree = await resolveWorkspaceCleanupActivityWorktree(
|
|
REPO,
|
|
makeWorktree(),
|
|
statPath,
|
|
readTextFile
|
|
)
|
|
|
|
expect(readTextFile).toHaveBeenCalledWith(path.join('/repo-feature', '.git'))
|
|
expect(statPath).toHaveBeenCalledWith(gitDirHeadPath)
|
|
expect(statPath).toHaveBeenCalledWith(commitEditMsgPath)
|
|
expect(statPath).toHaveBeenCalledWith(origHeadPath)
|
|
expect(worktree.lastActivityAt).toBe(70_000)
|
|
})
|
|
|
|
it('ignores gitdir metadata that git maintenance and git status restamp', async () => {
|
|
const gitDirPath = path.join('/repo', '.git', 'worktrees', 'repo-feature')
|
|
const statPath = vi.fn(async (targetPath: string) => {
|
|
const mtimes: Record<string, number> = {
|
|
'/repo-feature': 10_000,
|
|
[path.join('/repo-feature', '.git')]: 10_000,
|
|
[gitDirPath]: 90_000,
|
|
[path.join(gitDirPath, 'index')]: 90_000,
|
|
[path.join(gitDirPath, 'logs', 'HEAD')]: 90_000
|
|
}
|
|
return { mtimeMs: mtimes[targetPath] ?? 0 }
|
|
})
|
|
const readTextFile = vi.fn(async () => `gitdir: ${gitDirPath}\n`)
|
|
|
|
const worktree = await resolveWorkspaceCleanupActivityWorktree(
|
|
REPO,
|
|
makeWorktree(),
|
|
statPath,
|
|
readTextFile
|
|
)
|
|
|
|
expect(statPath).not.toHaveBeenCalledWith(gitDirPath)
|
|
expect(statPath).not.toHaveBeenCalledWith(path.join(gitDirPath, 'index'))
|
|
expect(statPath).not.toHaveBeenCalledWith(path.join(gitDirPath, 'logs', 'HEAD'))
|
|
expect(worktree.lastActivityAt).toBe(10_000)
|
|
})
|
|
|
|
it('reads the newest reflog entry timestamp instead of the reflog file mtime', async () => {
|
|
const gitDirPath = path.join('/repo', '.git', 'worktrees', 'repo-feature')
|
|
const reflogPath = path.join(gitDirPath, 'logs', 'HEAD')
|
|
const statPath = vi.fn(async () => ({ mtimeMs: 10_000 }))
|
|
const readTextFile = vi.fn(async (targetPath: string) =>
|
|
targetPath === reflogPath
|
|
? [
|
|
'0000 1111 Dev <dev@example.com> 1700000000 -0700\tbranch: Created from HEAD',
|
|
'1111 2222 Dev <dev@example.com> 1700000900 -0700\tcommit: work',
|
|
''
|
|
].join('\n')
|
|
: `gitdir: ${gitDirPath}\n`
|
|
)
|
|
|
|
const worktree = await resolveWorkspaceCleanupActivityWorktree(
|
|
REPO,
|
|
makeWorktree(),
|
|
statPath,
|
|
readTextFile
|
|
)
|
|
|
|
// Why: only the tail of the reflog is read — the newest entry is enough.
|
|
expect(readTextFile).toHaveBeenCalledWith(reflogPath, { tailBytes: 8192 })
|
|
expect(worktree.lastActivityAt).toBe(1_700_000_900_000)
|
|
})
|
|
|
|
it('falls back to a full reflog read when the newest entry exceeds the tail window', async () => {
|
|
const dir = await mkdtemp(path.join(os.tmpdir(), 'cleanup-activity-'))
|
|
try {
|
|
const gitDir = path.join(dir, 'gitdir')
|
|
await mkdir(path.join(gitDir, 'logs'), { recursive: true })
|
|
const worktreePath = path.join(dir, 'wt')
|
|
await mkdir(worktreePath, { recursive: true })
|
|
await writeFile(path.join(worktreePath, '.git'), `gitdir: ${gitDir}\n`)
|
|
// Why: a single record longer than the 8192-byte tail window keeps its
|
|
// timestamp before the window; the reader must fall back to a full read.
|
|
await writeFile(
|
|
path.join(gitDir, 'logs', 'HEAD'),
|
|
'0000 1111 Dev <dev@example.com> 1700000000 -0700\tbranch: Created from HEAD\n' +
|
|
`1111 2222 Dev <dev@example.com> 1700000900 -0700\tcommit: ${'x'.repeat(9000)}\n`
|
|
)
|
|
const statPath = vi.fn(async () => ({ mtimeMs: 10_000 }))
|
|
|
|
const worktree = await resolveWorkspaceCleanupActivityWorktree(
|
|
REPO,
|
|
makeWorktree({ path: worktreePath }),
|
|
statPath
|
|
)
|
|
|
|
expect(worktree.lastActivityAt).toBe(1_700_000_900_000)
|
|
} finally {
|
|
await rm(dir, { recursive: true, force: true })
|
|
}
|
|
})
|
|
|
|
it('degrades to other probes when the reflog was expired to an empty file', async () => {
|
|
const gitDirPath = path.join('/repo', '.git', 'worktrees', 'repo-feature')
|
|
const statPath = vi.fn(async () => ({ mtimeMs: 10_000 }))
|
|
const readTextFile = vi.fn(async (targetPath: string) =>
|
|
targetPath === path.join(gitDirPath, 'logs', 'HEAD') ? '' : `gitdir: ${gitDirPath}\n`
|
|
)
|
|
|
|
const worktree = await resolveWorkspaceCleanupActivityWorktree(
|
|
REPO,
|
|
makeWorktree(),
|
|
statPath,
|
|
readTextFile
|
|
)
|
|
|
|
expect(worktree.lastActivityAt).toBe(10_000)
|
|
})
|
|
|
|
it('resolves relative linked worktree gitdir pointers from the worktree path', async () => {
|
|
const gitDirPath = path.resolve('/repo-feature', '.repo/gitdir')
|
|
const commitEditMsgPath = path.join(gitDirPath, 'COMMIT_EDITMSG')
|
|
const statPath = vi.fn(async (targetPath: string) => ({
|
|
mtimeMs: targetPath === commitEditMsgPath ? 40_000 : 10_000
|
|
}))
|
|
const readTextFile = vi.fn(async () => 'gitdir: .repo/gitdir\n')
|
|
|
|
const worktree = await resolveWorkspaceCleanupActivityWorktree(
|
|
REPO,
|
|
makeWorktree(),
|
|
statPath,
|
|
readTextFile
|
|
)
|
|
|
|
expect(statPath).toHaveBeenCalledWith(commitEditMsgPath)
|
|
expect(worktree.lastActivityAt).toBe(40_000)
|
|
})
|
|
|
|
it('converts WSL linked worktree gitdir pointers before reading metadata', async () => {
|
|
const worktreePath = String.raw`\\wsl.localhost\Ubuntu\home\me\repo-feature`
|
|
const gitDirPath = String.raw`\\wsl.localhost\Ubuntu\home\me\repo\.git\worktrees\repo-feature`
|
|
const gitDirHeadPath = path.join(gitDirPath, 'HEAD')
|
|
const commitEditMsgPath = path.join(gitDirPath, 'COMMIT_EDITMSG')
|
|
const statPath = vi.fn(async (targetPath: string) => {
|
|
const mtimes: Record<string, number> = {
|
|
[worktreePath]: 10_000,
|
|
[path.join(worktreePath, '.git')]: 20_000,
|
|
[gitDirHeadPath]: 60_000,
|
|
[commitEditMsgPath]: 70_000
|
|
}
|
|
return { mtimeMs: mtimes[targetPath] ?? 0 }
|
|
})
|
|
const readTextFile = vi.fn(async () => 'gitdir: /home/me/repo/.git/worktrees/repo-feature\n')
|
|
|
|
const worktree = await resolveWorkspaceCleanupActivityWorktree(
|
|
REPO,
|
|
makeWorktree({ path: worktreePath }),
|
|
statPath,
|
|
readTextFile
|
|
)
|
|
|
|
expect(readTextFile).toHaveBeenCalledWith(path.join(worktreePath, '.git'))
|
|
expect(statPath).toHaveBeenCalledWith(gitDirHeadPath)
|
|
expect(statPath).toHaveBeenCalledWith(commitEditMsgPath)
|
|
expect(statPath).not.toHaveBeenCalledWith(
|
|
path.join('/home/me/repo/.git/worktrees/repo-feature', 'HEAD')
|
|
)
|
|
expect(worktree.lastActivityAt).toBe(70_000)
|
|
})
|
|
|
|
it('maps a drvfs gitdir pointer to its drive spelling on a Windows host', async () => {
|
|
const originalPlatform = process.platform
|
|
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
|
|
try {
|
|
const statPath = vi.fn(async (targetPath: string) => ({
|
|
mtimeMs: targetPath.endsWith('COMMIT_EDITMSG') ? 70_000 : 10_000
|
|
}))
|
|
// A WSL git writes the pointer in the guest namespace even for a drive-path worktree.
|
|
const readTextFile = vi.fn(
|
|
async (_targetPath: string) => 'gitdir: /mnt/c/Users/me/repo/.git/worktrees/repo-feature\n'
|
|
)
|
|
|
|
const worktree = await resolveWorkspaceCleanupActivityWorktree(
|
|
REPO,
|
|
makeWorktree({ path: String.raw`C:\Users\me\repo-feature` }),
|
|
statPath,
|
|
readTextFile
|
|
)
|
|
|
|
// Separators are normalized because the probe join uses the host separator;
|
|
// what this pins is the drive spelling of every resolved probe target.
|
|
const normalize = (target: string): string => target.replaceAll('/', '\\')
|
|
expect(statPath.mock.calls.map(([target]) => normalize(target)).sort()).toEqual(
|
|
[
|
|
String.raw`C:\Users\me\repo-feature`,
|
|
String.raw`C:\Users\me\repo-feature\.git`,
|
|
String.raw`C:\Users\me\repo\.git\worktrees\repo-feature\HEAD`,
|
|
String.raw`C:\Users\me\repo\.git\worktrees\repo-feature\COMMIT_EDITMSG`,
|
|
String.raw`C:\Users\me\repo\.git\worktrees\repo-feature\ORIG_HEAD`
|
|
].sort()
|
|
)
|
|
expect(readTextFile.mock.calls.map(([target]) => normalize(target))).toContain(
|
|
String.raw`C:\Users\me\repo\.git\worktrees\repo-feature\logs\HEAD`
|
|
)
|
|
expect(worktree.lastActivityAt).toBe(70_000)
|
|
} finally {
|
|
Object.defineProperty(process, 'platform', { configurable: true, value: originalPlatform })
|
|
}
|
|
})
|
|
|
|
it('keeps persisted activity when it is newer than local metadata', async () => {
|
|
const statPath = vi.fn(async () => ({ mtimeMs: 10_000 }))
|
|
|
|
const worktree = await resolveWorkspaceCleanupActivityWorktree(
|
|
REPO,
|
|
makeWorktree({ lastActivityAt: 30_000 }),
|
|
statPath
|
|
)
|
|
|
|
expect(worktree.lastActivityAt).toBe(30_000)
|
|
})
|
|
|
|
it('does not stat remote worktree paths', async () => {
|
|
const statPath = vi.fn(async () => ({ mtimeMs: 20_000 }))
|
|
|
|
const worktree = await resolveWorkspaceCleanupActivityWorktree(
|
|
{ ...REPO, connectionId: 'ssh-1' },
|
|
makeWorktree({ createdAt: 10_000 }),
|
|
statPath
|
|
)
|
|
|
|
expect(statPath).not.toHaveBeenCalled()
|
|
expect(worktree.lastActivityAt).toBe(10_000)
|
|
})
|
|
})
|