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
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,18 @@ Notable changes, newest first. Dates are the day the work landed on `main`.

The versions are what a release is tagged as; between tags, `main` is what is being used daily.

## Unreleased

### Fixed

- **Pointing Loadout at the repository you work in now finds it.** Choosing a project folder — one
with a `.git` and an `.opencode` in it — answered "no repositories found in here", because the
folder you chose was only ever a place to look *inside*, and the `.git` that proves it is a
repository is hidden and cannot be chosen in the picker either. A chosen folder under git is now
that project. A chosen folder with only a `.claude` still means look inside — that is
`~/Projects` with house instructions at the top — and becomes the project only when there is
nothing under it. Reported after install by somebody with one repository and no `~/Projects`.

## 0.3.4 — 2026-08-28

### Fixed
Expand Down
7 changes: 4 additions & 3 deletions Sources/LoadoutApp/ProjectsSettings.swift
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,8 @@ struct ProjectsSettings: View {
// The depth is interpolated, not spelled out: the sentence said two while the search
// went three deep, and the row below it printed the real number two lines away.
note: "Loadout looks inside these for repositories — anything with a .git or a .claude "
+ "inside, up to \(ProjectRoots.searchDepth) levels down.",
+ "inside, up to \(ProjectRoots.searchDepth) levels down. A folder that is itself a "
+ "repository counts as one, so you can point straight at the project you work in.",
footnote: "A project's own skills and commands appear once you pick it from the scope "
+ "button above the list."
) {
Expand Down Expand Up @@ -59,7 +60,7 @@ struct ProjectsSettings: View {
.buttonStyle(V2ToolbarButtonStyle(
prominent: model.projectRoots.folders.isEmpty, enabled: true
))
.help("Choose a folder Loadout should look in for repositories")
.help("Choose a folder that holds your repositories, or a repository itself")
.pointingHand()
}
}
Expand Down Expand Up @@ -117,7 +118,7 @@ struct ProjectsSettings: View {
panel.canChooseFiles = false
panel.allowsMultipleSelection = true
panel.prompt = "Use folder"
panel.message = "Choose a folder Loadout should look in for repositories."
panel.message = "Choose a folder that holds your repositories — or a repository itself."
guard panel.runModal() == .OK else { return }
// Appended rather than replacing, and a folder chosen twice is not added twice.
var folders = model.projectRoots.folders
Expand Down
7 changes: 4 additions & 3 deletions Sources/LoadoutApp/SettingsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -213,7 +213,7 @@ struct ProjectsTab: View {

HStack {
Button("Add folder…") { add() }
.help("Choose a folder Loadout should look in for repositories")
.help("Choose a folder that holds your repositories, or a repository itself")
.pointingHand()
Spacer()
Text(found)
Expand All @@ -225,7 +225,8 @@ struct ProjectsTab: View {
} footer: {
Text(
"Loadout looks inside these for repositories — a folder with a .git or a "
+ ".claude in it — up to two levels down. Each project's own skills and "
+ ".claude in it — up to \(ProjectRoots.searchDepth) levels down, and a folder "
+ "that is one itself counts. Each project's own skills and "
+ "commands then show up when you pick it from the scope button at the top of "
+ "the list."
)
Expand All @@ -252,7 +253,7 @@ struct ProjectsTab: View {
panel.canChooseFiles = false
panel.allowsMultipleSelection = true
panel.prompt = "Use folder"
panel.message = "Choose a folder Loadout should look in for repositories."
panel.message = "Choose a folder that holds your repositories — or a repository itself."
guard panel.runModal() == .OK else { return }
// Appended rather than replacing, and a folder chosen twice is not added twice.
var folders = model.projectRoots.folders
Expand Down
5 changes: 3 additions & 2 deletions Sources/LoadoutApp/WelcomeSheet.swift
Original file line number Diff line number Diff line change
Expand Up @@ -233,7 +233,8 @@ struct WelcomeSheet: View {
}

if offered.isEmpty {
Text("Nothing obvious found — point Loadout at a folder and it will look inside.")
Text("Nothing obvious found — point Loadout at a folder it should look inside, or at a "
+ "repository itself.")
.font(.system(size: 12))
.foregroundStyle(V2.textMid)
} else {
Expand Down Expand Up @@ -391,7 +392,7 @@ struct WelcomeSheet: View {
panel.canChooseFiles = false
panel.allowsMultipleSelection = true
panel.prompt = "Use folder"
panel.message = "Choose a folder Loadout should look in for repositories."
panel.message = "Choose a folder that holds your repositories — or a repository itself."
guard panel.runModal() == .OK else { return }
// Compared standardized, the way Settings › Projects does it. Raw `URL` equality counts
// `~/Projects` and `~/Projects/` as two different folders, and the panel hands back the
Expand Down
39 changes: 28 additions & 11 deletions Sources/LoadoutCore/ProjectRoots.swift
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,9 @@ public struct ProjectRoots: Sendable {
let base = folder.standardizedFileURL.path
let path = url.standardizedFileURL.path
if path.hasPrefix(base + "/") { return String(path.dropFirst(base.count + 1)) }
// The chosen folder is the project itself: it reads as its own name, not as the whole
// path to it, which is what the row already sits under.
if path == base { return url.lastPathComponent }
return abbreviate(url, home: home)
}

Expand All @@ -121,31 +124,45 @@ public struct ProjectRoots: Sendable {
/// Hidden folders are skipped, which keeps this out of `.git` internals, and `node_modules` is
/// skipped by name because it is deep, enormous, and never a project of yours.
///
/// The folder somebody *chose* is never itself the answer, even when it looks like a
/// repository. A real `~/Projects` had a `.claude` of its own — house instructions for the
/// whole tree — and the search stopped dead on it and reported one project where there were
/// ninety. What you point at is a place to look inside; only something nested can be the thing
/// found.
/// What somebody chose is first of all a place to look *inside*. A real `~/Projects` had a
/// `.claude` of its own — house instructions for the whole tree — and an earlier search
/// stopped dead on it and reported one project where there were ninety.
///
/// It can also be the project. Somebody who points the app straight at the repository they
/// work in was told "no repositories found in here" while standing in their own checkout —
/// and the `.git` that proves otherwise is hidden, so the picker would not let them choose it
/// instead. So a chosen folder under git is that project, full stop: what is nested inside a
/// checkout is its submodules and packages, not other projects of yours.
///
/// A chosen folder with only a `.claude` stays a place to look inside, and is taken as the
/// project just when nothing turned up in there. That is the `~/Projects` shape: house
/// instructions for a whole tree read as a project once, and hid ninety.
static func repositories(under folder: URL, depth: Int = searchDepth, isRoot: Bool = true) -> [URL] {
guard depth >= 0, isDirectory(folder) else { return [] }
if !isRoot, isRepository(folder) { return [folder] }
guard depth > 0 else { return [] }
if isRoot, hasGit(folder) { return [folder] }
guard depth > 0 else { return isRoot && isRepository(folder) ? [folder] : [] }

let fm = FileManager.default
guard let children = try? fm.contentsOfDirectory(
at: folder, includingPropertiesForKeys: [.isDirectoryKey], options: [.skipsHiddenFiles]
) else { return [] }
) else { return isRoot && isRepository(folder) ? [folder] : [] }

return children
let nested = children
.filter { isDirectory($0) && $0.lastPathComponent != "node_modules" }
.sorted { $0.lastPathComponent < $1.lastPathComponent }
.flatMap { repositories(under: $0, depth: depth - 1, isRoot: false) }
if isRoot, nested.isEmpty, isRepository(folder) { return [folder] }
return nested
}

static func isRepository(_ folder: URL) -> Bool {
isDirectory(folder.appendingPathComponent(".git"))
|| FileManager.default.fileExists(atPath: folder.appendingPathComponent(".git").path)
|| isDirectory(folder.appendingPathComponent(".claude"))
hasGit(folder) || isDirectory(folder.appendingPathComponent(".claude"))
}

/// A `.git` directory, or the file a worktree or submodule leaves in its place.
static func hasGit(_ folder: URL) -> Bool {
FileManager.default.fileExists(atPath: folder.appendingPathComponent(".git").path)
}

static func isDirectory(_ url: URL) -> Bool {
Expand Down
44 changes: 44 additions & 0 deletions Tests/LoadoutCoreTests/ProjectRootsTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,50 @@ final class ProjectRootsTests: XCTestCase {
XCTAssertFalse(found.contains { $0.name == "Projects" }, "the folder you chose is not a project")
}

/// Pointing straight at the repository you work in finds it, instead of an empty list.
///
/// Reported by somebody who chose their own project folder — a checkout with a `.git` and an
/// `.opencode` in it — and was told there were no repositories in there. The `.git` that would
/// have proved otherwise is hidden, so the picker would not let them choose it either.
func testChoosingARepositoryItselfFindsThatRepository() {
repo("dev/opencode-thing")
try! fm.createDirectory(
at: home.appendingPathComponent("dev/opencode-thing/.opencode"),
withIntermediateDirectories: true
)

let found = roots(["dev/opencode-thing"]).discover(home: home)

XCTAssertEqual(found.map(\.name), ["opencode-thing"])
XCTAssertEqual(found.map(\.relativePath), ["opencode-thing"], "shown by its name, not its path")
}

/// And a chosen repository that also has repositories inside it still reads as one project,
/// rather than becoming its own submodules.
func testAChosenRepositoryDoesNotBecomeItsSubmodules() {
repo("dev/monorepo")
repo("dev/monorepo/packages/web")

XCTAssertEqual(roots(["dev/monorepo"]).discover(home: home).map(\.name), ["monorepo"])
}

/// House instructions for a whole tree — a `.claude` at the top of `~/Projects` — lose to the
/// repositories underneath, which is the shape that made this rule in the first place.
func testAChosenFolderWithHouseInstructionsLosesToWhatIsInsideIt() {
try! fm.createDirectory(
at: home.appendingPathComponent("Projects/.claude"), withIntermediateDirectories: true
)
repo("Projects/brain-box")

XCTAssertEqual(roots(["Projects"]).discover(home: home).map(\.name), ["brain-box"])
}

/// An empty folder that is not a repository is still empty — the fallback does not invent one.
func testAnOrdinaryEmptyFolderStillFindsNothing() {
try! fm.createDirectory(at: home.appendingPathComponent("empty"), withIntermediateDirectories: true)
XCTAssertTrue(roots(["empty"]).discover(home: home).isEmpty)
}

/// Three levels down, which two was not: `~/Projects/PERSONAL/APPS/loadout` is a real path on a
/// real machine, and a shallower limit found none of them while reporting success.
func testARepositoryThreeLevelsDownIsFound() {
Expand Down