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
10 changes: 6 additions & 4 deletions src/Server/GitWorktree.fs
Original file line number Diff line number Diff line change
Expand Up @@ -68,15 +68,17 @@ let parseWorktreeList (porcelainOutput: string) =
| _ -> None)
|> Array.toList

let listWorktrees (repoRoot: string) =
/// Discovers the repo's worktrees. Returns `None` when the underlying git command
/// failed (timeout / non-zero exit / failed-to-start), so callers can distinguish a
/// transient git failure from a repo that genuinely has zero worktrees (`Some []`)
/// and retain last-known-good instead of blanking the list on a git hiccup.
let listWorktrees (repoRoot: string) : Async<WorktreeInfo list option> =
async {
let! output = runGit repoRoot "worktree list --porcelain"

return
output
|> Option.map parseWorktreeList
|> Option.defaultValue []
|> List.filter (fun wt -> Directory.Exists(wt.Path))
|> Option.map (parseWorktreeList >> List.filter (fun wt -> Directory.Exists(wt.Path)))
}

let parseCommitOutput (worktreePath: string) (output: string option) =
Expand Down
8 changes: 7 additions & 1 deletion src/Server/RefreshScheduler.fs
Original file line number Diff line number Diff line change
Expand Up @@ -347,6 +347,12 @@ let buildPhase2Tasks (filters: PathFilters) (repos: Map<RepoId, PerRepoState>) =
let buildPhase3Tasks (repos: Map<RepoId, PerRepoState>) =
repos |> Map.toList |> List.map (fun (repoId, _) -> RefreshPr repoId)

/// Maps a worktree-discovery result to the state update to post. `None` means the
/// git call failed, so no update is produced and the repo's last-known-good worktree
/// list is retained rather than blanked by a transient git hiccup.
let worktreeListUpdate (repoId: RepoId) (discovered: GitWorktree.WorktreeInfo list option) : StateMsg option =
discovered |> Option.map (fun worktrees -> UpdateWorktreeList(repoId, worktrees))

let private deadlineOf (activity: ActivityLevel) (lastRuns: Map<RefreshTask, DateTimeOffset>) (task: RefreshTask) =
lastRuns
|> Map.tryFind task
Expand All @@ -364,7 +370,7 @@ let private executeTask
let root = rootPaths |> Map.find repoId
let! worktrees = GitWorktree.listWorktrees root
let! upstreamRemote = GitWorktree.resolveUpstreamRemote root
agent.Post(UpdateWorktreeList(repoId, worktrees))
worktreeListUpdate repoId worktrees |> Option.iter agent.Post
agent.Post(UpdateUpstreamRemote(repoId, upstreamRemote))
let baseBranch = TreemonConfig.readBaseBranch root
agent.Post(UpdateBaseBranch(repoId, baseBranch))
Expand Down
41 changes: 40 additions & 1 deletion src/Tests/CreateWorktreeServerTests.fs
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,46 @@ let private writePostForkArgsScript (repoDir: string) =
File.WriteAllText(Path.Combine(repoDir, "post-fork.sh"), script)


// ─── resolveWorktreeCommand: pure command construction ───
// ─── listWorktrees: git-failure signal against real git repos ───

[<TestFixture>]
[<Category("Unit")>]
[<Category("Fast")>]
type ListWorktreesTests() =
/// NUnit lifecycle field — reassigned per test by [<SetUp>]/[<TearDown>].
let mutable tempDir = ""

[<SetUp>]
member _.Setup() =
tempDir <- Path.Combine(Path.GetTempPath(), $"treemon-listwt-{Guid.NewGuid():N}")
Directory.CreateDirectory(tempDir) |> ignore

[<TearDown>]
member _.TearDown() =
if Directory.Exists(tempDir) then
try Directory.Delete(tempDir, recursive = true)
with _ -> ()

[<Test>]
member _.``returns Some with the main worktree for a real repo``() =
let repoDir = Path.Combine(tempDir, "repo")
initRepoOnMain repoDir

match listWorktrees repoDir |> Async.RunSynchronously with
| None -> Assert.Fail("Expected Some worktree list for a real repo but got None")
| Some worktrees ->
Assert.That(worktrees.Length, Is.EqualTo(1), "a fresh repo has exactly its main worktree")
Assert.That(worktrees[0].Branch, Is.EqualTo(Some "main"))
Assert.That(Path.GetFileName(worktrees[0].Path), Is.EqualTo("repo"))

[<Test>]
member _.``returns None when the path is not a git repository``() =
let notARepo = Path.Combine(tempDir, "not-a-repo")
Directory.CreateDirectory(notARepo) |> ignore

let result = listWorktrees notARepo |> Async.RunSynchronously
Assert.That(result, Is.EqualTo(None), "a git failure must surface as None, not an empty list")


[<TestFixture>]
[<Category("Unit")>]
Expand Down
55 changes: 55 additions & 0 deletions src/Tests/SchedulerTests.fs
Original file line number Diff line number Diff line change
Expand Up @@ -265,6 +265,32 @@ type StateAgentTests() =
}
|> Async.RunSynchronously

[<Test>]
member _.``Failed worktree discovery retains the existing list``() =
async {
let agent = createAgent ()

let worktrees =
[ makeWorktree "/repo/main" "main"
makeWorktree "/repo/feature" "feature" ]

agent.Post(UpdateWorktreeList(testRepoId, worktrees))
do! waitForAgent agent

// A git failure surfaces as None from listWorktrees, so executeTask posts
// nothing — driving the exact skip-on-None decision used in production.
worktreeListUpdate testRepoId None |> Option.iter agent.Post
do! waitForAgent agent

let! state = agent.PostAndAsyncReply(GetState)
let repo = getRepo state

Assert.That(repo.WorktreeList.Length, Is.EqualTo(2), "a failed discovery must not blank the last-known-good list")
Assert.That(repo.WorktreeList |> List.map _.Path, Is.EquivalentTo([ "/repo/main"; "/repo/feature" ]))
Assert.That(repo.IsReady, Is.True)
}
|> Async.RunSynchronously

[<Test>]
member _.``Event ring buffer caps at 50``() =
async {
Expand Down Expand Up @@ -437,6 +463,35 @@ type StateAgentTests() =
|> Async.RunSynchronously


[<TestFixture>]
[<Category("Unit")>]
[<Category("Fast")>]
type WorktreeListUpdateTests() =

[<Test>]
member _.``Successful discovery produces an UpdateWorktreeList message``() =
let worktrees = [ makeWorktree "/repo/main" "main" ]

match worktreeListUpdate testRepoId (Some worktrees) with
| Some(UpdateWorktreeList(repoId, wts)) ->
Assert.That(repoId, Is.EqualTo(testRepoId))
Assert.That(wts, Is.EqualTo(worktrees))
| other -> Assert.Fail($"Expected Some(UpdateWorktreeList ...) but got {other}")

[<Test>]
member _.``Empty-but-successful discovery still produces an update``() =
match worktreeListUpdate testRepoId (Some []) with
| Some(UpdateWorktreeList(repoId, wts)) ->
Assert.That(repoId, Is.EqualTo(testRepoId))
Assert.That(wts, Is.Empty)
| other -> Assert.Fail($"Expected Some(UpdateWorktreeList ...) but got {other}")

[<Test>]
member _.``Failed discovery (None) produces no message``() =
let result = worktreeListUpdate testRepoId None
Assert.That(result, Is.EqualTo(None))


[<TestFixture>]
[<Category("Unit")>]
[<Category("Fast")>]
Expand Down
Loading