Skip to content

Commit 808676d

Browse files
Add repository-specific hooks support for prune
Introduces a hooks system that allows repos to define custom scripts executed during prune operations. Hooks are placed in .sprout/hooks/ within each repository. Features: - HooksService to discover and execute hooks from .sprout/hooks/ - post-prune hook runs after each worktree is removed - Environment variables passed to hooks: SPROUT_WORKTREE_PATH, SPROUT_BRANCH, SPROUT_REPO_ROOT - Dry-run mode shows which hooks would run - Verbose flag (-v) for detailed hook execution output Example use case: Clean up Xcode DerivedData when pruning worktrees from iOS projects. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent c63490b commit 808676d

File tree

2 files changed

+194
-0
lines changed

2 files changed

+194
-0
lines changed

Sources/sprout/Commands/Prune.swift

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,13 +30,25 @@ struct Prune: AsyncParsableCommand {
3030
@Argument(help: "Branch name or pattern to prune (optional - launches picker if not provided)")
3131
var branch: String?
3232

33+
@Flag(name: [.short, .long], help: "Print verbose output")
34+
var verbose: Bool = false
35+
3336
@MainActor
3437
func run() async throws {
3538
let gitService = GitService()
39+
let hooksService = HooksService()
3640

3741
// Get list of worktrees
3842
let worktrees = try await gitService.listWorktrees()
3943

44+
// Find the main repo root for hooks
45+
let mainRepoRoot: String?
46+
if let firstWorktree = worktrees.first {
47+
mainRepoRoot = hooksService.findMainRepoRoot(from: firstWorktree.path)
48+
} else {
49+
mainRepoRoot = try? await gitService.getRepoRoot()
50+
}
51+
4052
if worktrees.isEmpty {
4153
print("No worktrees found.")
4254
return
@@ -132,13 +144,44 @@ struct Prune: AsyncParsableCommand {
132144
for wt in toRemove {
133145
if dryRun {
134146
print("Would remove: \(wt.branch) (\(wt.path))")
147+
if let repoRoot = mainRepoRoot {
148+
let hookPath = "\(repoRoot)/.sprout/hooks/post-prune"
149+
if FileManager.default.fileExists(atPath: hookPath) {
150+
print(" Would run post-prune hook")
151+
}
152+
}
135153
} else {
136154
print("Removing \(wt.branch)...", terminator: " ")
137155
fflush(stdout)
138156
do {
157+
// Capture worktree path before removal for hook
158+
let worktreePath = wt.path
159+
139160
try await gitService.removeWorktree(at: wt.path)
140161
try await gitService.deleteBranch(wt.branch)
141162
print("done")
163+
164+
// Run post-prune hook if it exists
165+
if let repoRoot = mainRepoRoot {
166+
let env = HooksService.HookEnvironment(
167+
worktreePath: worktreePath,
168+
branch: wt.branch,
169+
repoRoot: repoRoot
170+
)
171+
do {
172+
let hookRan = try await hooksService.run(
173+
.postPrune,
174+
in: repoRoot,
175+
environment: env,
176+
verbose: verbose
177+
)
178+
if hookRan && verbose {
179+
print(" post-prune hook completed")
180+
}
181+
} catch {
182+
print(" post-prune hook failed: \(error)")
183+
}
184+
}
142185
} catch {
143186
print("error: \(error)")
144187
}
Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
1+
import Foundation
2+
3+
/// Executes repository-specific hooks from .sprout/hooks/
4+
struct HooksService {
5+
/// Available hook types
6+
enum Hook: String {
7+
case postPrune = "post-prune"
8+
case prePrune = "pre-prune"
9+
case postLaunch = "post-launch"
10+
}
11+
12+
/// Environment variables passed to hook scripts
13+
struct HookEnvironment {
14+
var worktreePath: String?
15+
var branch: String?
16+
var repoRoot: String?
17+
18+
var asDict: [String: String] {
19+
var env: [String: String] = [:]
20+
if let path = worktreePath {
21+
env["SPROUT_WORKTREE_PATH"] = path
22+
}
23+
if let branch = branch {
24+
env["SPROUT_BRANCH"] = branch
25+
}
26+
if let root = repoRoot {
27+
env["SPROUT_REPO_ROOT"] = root
28+
}
29+
return env
30+
}
31+
}
32+
33+
/// Run a hook if it exists in the repository
34+
/// - Parameters:
35+
/// - hook: The hook type to run
36+
/// - repoRoot: Root directory of the repository
37+
/// - environment: Environment variables to pass to the script
38+
/// - verbose: Whether to print debug output
39+
/// - Returns: true if hook was found and executed, false if no hook exists
40+
@discardableResult
41+
func run(
42+
_ hook: Hook,
43+
in repoRoot: String,
44+
environment: HookEnvironment,
45+
verbose: Bool = false
46+
) async throws -> Bool {
47+
let hookPath = "\(repoRoot)/.sprout/hooks/\(hook.rawValue)"
48+
let expandedPath = NSString(string: hookPath).expandingTildeInPath
49+
50+
// Check if hook exists
51+
guard FileManager.default.fileExists(atPath: expandedPath) else {
52+
if verbose {
53+
print("No \(hook.rawValue) hook found at \(hookPath)")
54+
}
55+
return false
56+
}
57+
58+
// Check if executable
59+
guard FileManager.default.isExecutableFile(atPath: expandedPath) else {
60+
print("Warning: Hook exists but is not executable: \(hookPath)")
61+
print("Run: chmod +x \(hookPath)")
62+
return false
63+
}
64+
65+
if verbose {
66+
print("Running \(hook.rawValue) hook...")
67+
}
68+
69+
let process = Process()
70+
process.executableURL = URL(fileURLWithPath: expandedPath)
71+
process.currentDirectoryURL = URL(fileURLWithPath: repoRoot)
72+
73+
// Merge with existing environment
74+
var processEnv = ProcessInfo.processInfo.environment
75+
for (key, value) in environment.asDict {
76+
processEnv[key] = value
77+
}
78+
process.environment = processEnv
79+
80+
let stdout = Pipe()
81+
let stderr = Pipe()
82+
process.standardOutput = stdout
83+
process.standardError = stderr
84+
85+
try process.run()
86+
process.waitUntilExit()
87+
88+
// Print output
89+
let outData = stdout.fileHandleForReading.readDataToEndOfFile()
90+
if let outStr = String(data: outData, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines),
91+
!outStr.isEmpty {
92+
print(outStr)
93+
}
94+
95+
let errData = stderr.fileHandleForReading.readDataToEndOfFile()
96+
if let errStr = String(data: errData, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines),
97+
!errStr.isEmpty {
98+
FileHandle.standardError.write(Data(errStr.utf8))
99+
FileHandle.standardError.write(Data("\n".utf8))
100+
}
101+
102+
if process.terminationStatus != 0 {
103+
throw HookError.executionFailed(hook.rawValue, Int(process.terminationStatus))
104+
}
105+
106+
return true
107+
}
108+
109+
/// Find the main repo root from a worktree path
110+
/// Worktrees are typically at ../worktrees/{branch}, so main repo is a sibling
111+
func findMainRepoRoot(from worktreePath: String) -> String? {
112+
// Check if this worktree has a .git file (not directory) pointing to main repo
113+
let gitPath = "\(worktreePath)/.git"
114+
115+
guard FileManager.default.fileExists(atPath: gitPath) else {
116+
return nil
117+
}
118+
119+
// For worktrees, .git is a file containing "gitdir: /path/to/main/.git/worktrees/branch"
120+
guard let contents = try? String(contentsOfFile: gitPath, encoding: .utf8),
121+
contents.hasPrefix("gitdir:") else {
122+
// This is the main repo (has .git directory)
123+
return worktreePath
124+
}
125+
126+
// Parse the gitdir path to find main repo
127+
// Format: gitdir: /path/to/main/.git/worktrees/branchname
128+
let gitdirPath = contents
129+
.replacingOccurrences(of: "gitdir:", with: "")
130+
.trimmingCharacters(in: .whitespacesAndNewlines)
131+
132+
// Go up from .git/worktrees/branch to get main repo root
133+
if let range = gitdirPath.range(of: "/.git/worktrees/") {
134+
return String(gitdirPath[..<range.lowerBound])
135+
}
136+
137+
return nil
138+
}
139+
}
140+
141+
/// Errors from hook execution
142+
enum HookError: Error, CustomStringConvertible {
143+
case executionFailed(String, Int)
144+
145+
var description: String {
146+
switch self {
147+
case .executionFailed(let hook, let code):
148+
return "Hook '\(hook)' failed with exit code \(code)"
149+
}
150+
}
151+
}

0 commit comments

Comments
 (0)