Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 48 additions & 11 deletions Sources/GitWorktreeManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -344,7 +344,39 @@ struct GitWorktreeManager {
":(glob)**/.env*", ":(exclude,glob)**/node_modules/**",
]
), result.outcome == .exited, result.exitStatus == 0, let stdout = result.stdout else { return nil }
return !stdout.isEmpty
// git lists every nested repository as `dir/` whatever the pathspec says, so only
// entries that are themselves `.env*` files count.
return stdout.split(separator: "\0").contains { entry in
guard !entry.hasSuffix("/") else { return false }
let components = entry.split(separator: "/")
return components.last?.hasPrefix(".env") == true && !components.dropLast().contains("node_modules")
}
}

/// Ignored directories whose contents a build or package install recreates. Matched against
/// every directory component of an ignored path.
nonisolated static let rebuildableIgnoredDirectoryNames: Set<String> = [
"node_modules", ".build", "build", "dist", ".next", ".nuxt", ".turbo", ".cache", "target",
"Pods", "DerivedData", ".gradle", "coverage", "__pycache__", ".venv", "venv",
]

/// Whether the worktree at `path` holds ignored files outside the
/// `rebuildableIgnoredDirectoryNames` directories (`.dev.vars`, keys, local databases, a
/// nested clone), which `git worktree remove` would delete. Output over the stdout limit
/// counts as true; nil when git could not answer.
nonisolated static func ignoredFilesOutsideRebuildableDirectories(path: String) -> Bool? {
// The pathspecs only keep the output small. git still lists a nested repository inside an
// excluded directory (as `dir/`), so the component check below decides.
let exclusions = rebuildableIgnoredDirectoryNames.sorted().map { ":(exclude,glob)**/\($0)/**" }
guard let result = runGitCommand(
directory: path,
arguments: ["ls-files", "--others", "--ignored", "--exclude-standard", "-z", "--"] + exclusions
) else { return nil }
if result.outcome == .stdoutLimitExceeded { return true }
guard result.outcome == .exited, result.exitStatus == 0, let stdout = result.stdout else { return nil }
return stdout.split(separator: "\0").contains { entry in
!entry.split(separator: "/").dropLast().contains { rebuildableIgnoredDirectoryNames.contains(String($0)) }
}
}

// MARK: - Process plumbing
Expand Down Expand Up @@ -408,13 +440,14 @@ enum ProgramaWorktreeSettings {

/// `worktrees.autoRemoveMerged`: whether the background monitor removes merged, idle
/// Programa-created worktrees.
static func autoRemoveMerged() -> Bool {
(readSettingsRoot()?["worktrees"] as? [String: Any])?["autoRemoveMerged"] as? Bool ?? defaultAutoRemoveMerged
static func autoRemoveMerged(settingsURL: URL = settingsFileURL) -> Bool {
(readSettingsRoot(settingsURL: settingsURL)?["worktrees"] as? [String: Any])?["autoRemoveMerged"] as? Bool
?? defaultAutoRemoveMerged
}

/// `worktrees.autoRemoveAfterHours`, clamped to at least one hour.
static func autoRemoveAfterHours() -> Double {
guard let section = readSettingsRoot()?["worktrees"] as? [String: Any],
static func autoRemoveAfterHours(settingsURL: URL = settingsFileURL) -> Double {
guard let section = readSettingsRoot(settingsURL: settingsURL)?["worktrees"] as? [String: Any],
let number = section["autoRemoveAfterHours"] as? NSNumber,
CFGetTypeID(number) != CFBooleanGetTypeID(),
number.doubleValue.isFinite else {
Expand All @@ -423,10 +456,11 @@ enum ProgramaWorktreeSettings {
return max(minimumAutoRemoveAfterHours, number.doubleValue)
}

static func readSettingsRoot() -> [String: Any]? {
let home = FileManager.default.homeDirectoryForCurrentUser.path
let path = (home as NSString).appendingPathComponent(".config/programa/settings.json")
guard let data = FileManager.default.contents(atPath: path), !data.isEmpty else { return nil }
static let settingsFileURL = FileManager.default.homeDirectoryForCurrentUser
.appendingPathComponent(".config/programa/settings.json")

static func readSettingsRoot(settingsURL: URL = settingsFileURL) -> [String: Any]? {
guard let data = FileManager.default.contents(atPath: settingsURL.path), !data.isEmpty else { return nil }
guard let sanitized = try? JSONCParser.preprocess(data: data),
let object = try? JSONSerialization.jsonObject(with: sanitized, options: []) else {
return nil
Expand All @@ -436,8 +470,11 @@ enum ProgramaWorktreeSettings {
}

enum ProgramaStorageSettings {
static func autoCleanCaches() -> Bool {
(ProgramaWorktreeSettings.readSettingsRoot()?["storage"] as? [String: Any])?["autoCleanCaches"] as? Bool ?? true
static let defaultAutoCleanCaches = true

static func autoCleanCaches(settingsURL: URL = ProgramaWorktreeSettings.settingsFileURL) -> Bool {
(ProgramaWorktreeSettings.readSettingsRoot(settingsURL: settingsURL)?["storage"] as? [String: Any])?["autoCleanCaches"]
as? Bool ?? defaultAutoCleanCaches
}

static let defaultLowDiskWarningGB: Double = 20
Expand Down
158 changes: 108 additions & 50 deletions Sources/WorktreeStorage.swift
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,9 @@ struct WorktreeStorageEntry: Equatable, Sendable {
case dirty
/// Ignored `.env*` files, which `git worktree remove` would delete.
case envFiles = "env_files"
/// Other ignored files outside rebuildable build and dependency directories. Only
/// policies with `keepsIgnoredFiles` (the background monitor) apply it.
case ignoredFiles = "ignored_files"
case notMerged = "not_merged"
case openInWorkspace = "open_in_workspace"
case processRunning = "process_running"
Expand All @@ -30,38 +33,53 @@ struct WorktreeStorageEntry: Equatable, Sendable {
let path: String
let branch: String?
let headSHA: String?
let sizeBytes: Int64
var sizeBytes: Int64
/// The size walk stopped early (time limit or unreadable entries), so `sizeBytes` is a floor.
let sizePartial: Bool
var sizePartial: Bool
/// Newest mtime of the worktree's git `index`, `HEAD` and `logs/HEAD`.
let lastModified: Date?
let isProgramaCreated: Bool
let isDirty: Bool?
var isDirty: Bool?
/// Ignored `.env*` files outside `node_modules/`. Always blocks, with no override.
let hasIgnoredEnvFiles: Bool?
var hasIgnoredEnvFiles: Bool?
/// Informational: HEAD holds commits no remote-tracking ref contains. Not a blocker, because
/// removing a worktree never deletes its branch.
let hasUnpushedOrUnmergedCommits: Bool?
let isMerged: Bool?
var hasUnpushedOrUnmergedCommits: Bool?
var isMerged: Bool?
let isOpenInWorkspace: Bool
let isProcessRunningInside: Bool
/// Ignored files outside `GitWorktreeManager.rebuildableIgnoredDirectoryNames` directories.
var hasIgnoredFiles: Bool? = nil

/// The first reason to keep this worktree under `policy`, or nil when it may be removed.
func removalBlocker(policy: WorktreeRemovalPolicy, now: Date) -> RemovalBlocker? {
if !isProgramaCreated, !policy.includeExternal { return .external }
guard let isDirty, let hasIgnoredEnvFiles, let isMerged else { return .statusUnknown }
guard let isDirty, let hasIgnoredEnvFiles, let isMerged,
!policy.keepsIgnoredFiles || hasIgnoredFiles != nil else { return .statusUnknown }
if isDirty { return .dirty }
if hasIgnoredEnvFiles { return .envFiles }
if policy.keepsIgnoredFiles, hasIgnoredFiles == true { return .ignoredFiles }
if !isMerged { return .notMerged }
if isOpenInWorkspace { return .openInWorkspace }
if isProcessRunningInside { return .processRunning }
if let gracePeriod = policy.gracePeriod {
guard let lastModified, now.timeIntervalSince(lastModified) >= gracePeriod else {
return .recentlyUsed
}
}
if isRecentlyUsed(policy: policy, now: now) { return .recentlyUsed }
return nil
}

/// Whether `removalBlocker` is non-nil whatever the git probes report, so a pass that only
/// removes can skip them.
func isBlockedWithoutProbes(policy: WorktreeRemovalPolicy, now: Date) -> Bool {
(!isProgramaCreated && !policy.includeExternal)
|| isOpenInWorkspace
|| isProcessRunningInside
|| isRecentlyUsed(policy: policy, now: now)
}

private func isRecentlyUsed(policy: WorktreeRemovalPolicy, now: Date) -> Bool {
guard let gracePeriod = policy.gracePeriod else { return false }
guard let lastModified else { return true }
return now.timeIntervalSince(lastModified) < gracePeriod
}
}

/// Which conditions a removal pass waives. The background monitor uses `automatic`; only a
Expand All @@ -70,9 +88,12 @@ struct WorktreeRemovalPolicy: Equatable, Sendable {
/// nil skips the recent-use check.
var gracePeriod: TimeInterval?
var includeExternal: Bool
/// Keep worktrees holding ignored files outside rebuildable directories. A manual clean
/// previews and confirms what it removes, so only the unattended pass applies this.
var keepsIgnoredFiles = false

static func automatic(gracePeriodHours: Double) -> WorktreeRemovalPolicy {
WorktreeRemovalPolicy(gracePeriod: gracePeriodHours * 3600, includeExternal: false)
WorktreeRemovalPolicy(gracePeriod: gracePeriodHours * 3600, includeExternal: false, keepsIgnoredFiles: true)
}
}

Expand Down Expand Up @@ -123,11 +144,19 @@ enum WorktreeStorage {

// MARK: Scan

/// Which worktrees a removal-only scan may leave out: the ones `policy` blocks at `now`
/// before any git probe or size walk runs.
struct RemovalFilter: Sendable {
let policy: WorktreeRemovalPolicy
let now: Date
}

/// Scans every repo reachable from `openDirectories` or `worktrees.directory`.
static func scanAll(
openDirectories: [String],
programaDirectory: String = ProgramaWorktreeSettings.resolvedDirectory(),
sizeWalkTimeout: TimeInterval = defaultSizeWalkTimeout
sizeWalkTimeout: TimeInterval = defaultSizeWalkTimeout,
removalFilter: RemovalFilter? = nil
) -> [WorktreeStorageEntry] {
let processCwds = processWorkingDirectories()
let candidates = openDirectories + programaWorktreeCandidates(in: programaDirectory)
Expand All @@ -136,18 +165,21 @@ enum WorktreeStorage {
programaDirectory: programaDirectory,
openDirectories: openDirectories,
processCwds: processCwds,
sizeWalkTimeout: sizeWalkTimeout
sizeWalkTimeout: sizeWalkTimeout,
removalFilter: removalFilter
)
}

/// Classifies every linked worktree of `repositories`. The main worktree, bare entries and
/// worktrees whose directory is gone are skipped.
/// worktrees whose directory is gone are skipped, and so are the ones `removalFilter`
/// blocks without probing.
static func scan(
repositories: [Repository],
programaDirectory: String,
openDirectories: [String],
processCwds: Set<String>,
sizeWalkTimeout: TimeInterval = defaultSizeWalkTimeout
sizeWalkTimeout: TimeInterval = defaultSizeWalkTimeout,
removalFilter: RemovalFilter? = nil
) -> [WorktreeStorageEntry] {
let programaRoot = resolvedPath(programaDirectory)
let resolvedOpenDirectories = openDirectories.map(resolvedPath)
Expand All @@ -163,15 +195,18 @@ enum WorktreeStorage {
guard !linked.isEmpty else { continue }
let defaultBranch = GitWorktreeManager.defaultRemoteBranch(repoRoot: repository.root)
for worktree in linked {
entries.append(classify(
if let entry = classify(
worktree,
repoRoot: repository.root,
defaultBranch: defaultBranch,
programaRoot: programaRoot,
openDirectories: resolvedOpenDirectories,
processCwds: resolvedCwds,
sizeWalkTimeout: sizeWalkTimeout
))
sizeWalkTimeout: sizeWalkTimeout,
removalFilter: removalFilter
) {
entries.append(entry)
}
}
}
return entries
Expand All @@ -184,49 +219,56 @@ enum WorktreeStorage {
programaRoot: String,
openDirectories: [String],
processCwds: Set<String>,
sizeWalkTimeout: TimeInterval
) -> WorktreeStorageEntry {
sizeWalkTimeout: TimeInterval,
removalFilter: RemovalFilter?
) -> WorktreeStorageEntry? {
let path = worktree.path
let resolved = resolvedPath(path)
// Read before any git call in the worktree so our own probes cannot refresh it.
let modified = Self.lastModified(worktreePath: path)
let isDirty = GitWorktreeManager.workingTreeIsDirty(path: path)
let hasIgnoredEnvFiles = GitWorktreeManager.ignoredEnvFiles(path: path)

var isMerged: Bool? = false
if let head = worktree.headSHA, let defaultBranch {
isMerged = GitWorktreeManager.isAncestor(head, of: defaultBranch, repoRoot: repoRoot)
}
if isMerged != true, isDirty == false, let branch = worktree.branch,
GitWorktreeManager.upstreamIsGone(branch: branch, repoRoot: repoRoot) == true {
isMerged = true
}
let hasUnpushed = worktree.headSHA.flatMap {
GitWorktreeManager.hasCommitsMissingFromRemotes($0, repoRoot: repoRoot)
}
let size = directorySize(atPath: path, timeout: sizeWalkTimeout)

return WorktreeStorageEntry(
var entry = WorktreeStorageEntry(
repoRoot: repoRoot,
path: path,
branch: worktree.branch,
headSHA: worktree.headSHA,
sizeBytes: size.bytes,
sizePartial: size.partial,
lastModified: modified,
sizeBytes: 0,
sizePartial: true,
lastModified: Self.lastModified(worktreePath: path),
isProgramaCreated: isPath(resolved, equalToOrInside: programaRoot, caseInsensitive: false)
&& resolved != programaRoot,
isDirty: isDirty,
hasIgnoredEnvFiles: hasIgnoredEnvFiles,
hasUnpushedOrUnmergedCommits: hasUnpushed,
isMerged: isMerged,
isDirty: nil,
hasIgnoredEnvFiles: nil,
hasUnpushedOrUnmergedCommits: nil,
isMerged: nil,
isOpenInWorkspace: openDirectories.contains {
isPath($0, equalToOrInside: resolved, caseInsensitive: true)
},
isProcessRunningInside: processCwds.contains {
isPath($0, equalToOrInside: resolved, caseInsensitive: true)
}
)
if let removalFilter, entry.isBlockedWithoutProbes(policy: removalFilter.policy, now: removalFilter.now) {
return nil
}

let isDirty = GitWorktreeManager.workingTreeIsDirty(path: path)
entry.isDirty = isDirty
entry.hasIgnoredEnvFiles = GitWorktreeManager.ignoredEnvFiles(path: path)
entry.hasIgnoredFiles = GitWorktreeManager.ignoredFilesOutsideRebuildableDirectories(path: path)

var isMerged: Bool? = false
if let head = worktree.headSHA, let defaultBranch {
isMerged = GitWorktreeManager.isAncestor(head, of: defaultBranch, repoRoot: repoRoot)
}
if isMerged != true, isDirty == false, let branch = worktree.branch,
GitWorktreeManager.upstreamIsGone(branch: branch, repoRoot: repoRoot) == true {
isMerged = true
}
entry.isMerged = isMerged
entry.hasUnpushedOrUnmergedCommits = worktree.headSHA.flatMap {
GitWorktreeManager.hasCommitsMissingFromRemotes($0, repoRoot: repoRoot)
}
(entry.sizeBytes, entry.sizePartial) = directorySize(atPath: path, timeout: sizeWalkTimeout)
return entry
}

// MARK: Clean
Expand Down Expand Up @@ -276,6 +318,18 @@ enum WorktreeStorage {
outcome.kept.append(KeptWorktree(entry: entry, reason: .blocker(.envFiles)))
continue
}
if policy.keepsIgnoredFiles {
switch GitWorktreeManager.ignoredFilesOutsideRebuildableDirectories(path: entry.path) {
case false?:
break
case true?:
outcome.kept.append(KeptWorktree(entry: entry, reason: .blocker(.ignoredFiles)))
continue
case nil:
outcome.kept.append(KeptWorktree(entry: entry, reason: .blocker(.statusUnknown)))
continue
}
}
switch GitWorktreeManager.remove(repoRoot: entry.repoRoot, path: entry.path, force: false) {
case .success:
outcome.removed.append(entry)
Expand Down Expand Up @@ -498,11 +552,15 @@ final class WorktreeStorageMonitor: @unchecked Sendable {
defer { WorktreeStorage.passLock.unlock() }
if ProgramaWorktreeSettings.autoRemoveMerged() {
let policy = WorktreeRemovalPolicy.automatic(gracePeriodHours: ProgramaWorktreeSettings.autoRemoveAfterHours())
let entries = WorktreeStorage.scanAll(openDirectories: Self.openDirectoriesFromMain())
let now = Date()
let entries = WorktreeStorage.scanAll(
openDirectories: Self.openDirectoriesFromMain(),
removalFilter: WorktreeStorage.RemovalFilter(policy: policy, now: now)
)
let outcome = WorktreeStorage.clean(
entries: entries,
policy: policy,
now: Date(),
now: now,
dryRun: false,
openDirectories: Self.openDirectoriesFromMain,
processCwds: WorktreeStorage.processWorkingDirectories
Expand All @@ -515,7 +573,7 @@ final class WorktreeStorageMonitor: @unchecked Sendable {
logger.error("git refused to remove \(kept.entry.path, privacy: .private): \(message, privacy: .private)")
}
}
logger.info("Worktree cleanup scanned \(entries.count, privacy: .public), removed \(outcome.removed.count, privacy: .public), reclaimed \(outcome.reclaimedBytes, privacy: .public) bytes")
logger.info("Worktree cleanup probed \(entries.count, privacy: .public) unblocked candidates, removed \(outcome.removed.count, privacy: .public), reclaimed \(outcome.reclaimedBytes, privacy: .public) bytes")
}
if ProgramaStorageSettings.autoCleanCaches() {
let items = StorageCategories.scan()
Expand Down
5 changes: 4 additions & 1 deletion docs/workspace-interface.md
Original file line number Diff line number Diff line change
Expand Up @@ -147,7 +147,10 @@ shown below each session; these measurements stay cached for two minutes and ski

Each **Clean** button previews the items and total size before asking for confirmation.
Worktree cleaning ignores the automatic cleanup grace period but keeps dirty, unmerged,
open, or busy worktrees. Cache rows list blocked items with the reason they are kept.
open, or busy worktrees. The automatic background cleanup also keeps any worktree that holds
git-ignored files outside build and dependency folders (`node_modules`, `build`, `dist`, `target`,
`.venv` and similar), such as `.dev.vars`, keys, local databases or a nested clone.
Cache rows list blocked items with the reason they are kept.
After cleaning, the panel reports the removed items and reclaimed space.

Programa checks available space 60 seconds after launch and every 15 minutes. When it falls
Expand Down
Loading
Loading