Skip to content
Merged
11 changes: 10 additions & 1 deletion docs/spec/worktree-monitor.md
Original file line number Diff line number Diff line change
Expand Up @@ -180,10 +180,19 @@ Intervals adapt to user activity level (Active / Idle / Deep Idle). See `docs/sp
For fork workflows (push to fork, PRs in upstream repo), treemon auto-detects and uses the correct remote:

- **Resolution order**: `.treemon.json` `"upstreamRemote"` field → auto-detect `upstream` remote → fall back to `origin`
- **Affects**: PR fetching (remote URL), main branch comparisons (`{remote}/main`), fetch cycle, sync merge target
- **Affects**: PR fetching (remote URL), base branch comparisons (`{remote}/{baseBranch}`), fetch cycle, sync merge target
- **Stored** per-repo in `PerRepoState.UpstreamRemote`, resolved during worktree list refresh
- **Config example**: `{ "upstreamRemote": "upstream" }` in `.treemon.json` at repo root

### Base Branch Resolution

Each repo can configure which branch is considered the "base" for ahead/behind counts, diff stats, fetch, fast-forward, and sync operations:

- **Resolution**: `.treemon.json` `"baseBranch"` field → default `"main"`
- **Affects**: `git rev-list` behind/commit counts, `git diff --shortstat`, `git fetch`, fast-forward, merge/rebase targets, branch sort priority
- **Stored** per-repo in `PerRepoState.BaseBranch`, resolved during worktree list refresh
- **Config example**: `{ "baseBranch": "dev" }` in `.treemon.json` at repo root

### CommentSummary

- `WithResolution of unresolved * total` — thread resolution tracking (both AzDo and GitHub)
Expand Down
5 changes: 4 additions & 1 deletion src/Client/App.fs
Original file line number Diff line number Diff line change
Expand Up @@ -209,7 +209,8 @@ let update msg model =
IsCollapsed =
if isFirstLoad then response.CollapsedRepos |> Set.contains r.RepoId
else existingCollapse |> Map.tryFind r.RepoId |> Option.defaultValue false
Provider = r.Provider })
Provider = r.Provider
BaseBranch = r.BaseBranch })
|> filterDeletedPaths stillPending
{ model with
Repos = repos
Expand Down Expand Up @@ -1458,6 +1459,8 @@ let repoSectionHeader dispatch (focusedElement: FocusTarget option) (repo: RepoM
Html.span [ prop.className "collapse-arrow"; prop.text arrow ]
Html.span [ prop.className "repo-name"; prop.text repo.Name ]
providerIcon repo.Provider
if repo.BaseBranch <> "main" then
Html.span [ prop.className "deploy-branch"; prop.text repo.BaseBranch ]
if repo.IsCollapsed then
Html.span [
prop.className "repo-ct-dots"
Expand Down
3 changes: 2 additions & 1 deletion src/Client/Navigation.fs
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,8 @@ type RepoModel =
ArchivedWorktrees: WorktreeStatus list
IsReady: bool
IsCollapsed: bool
Provider: RepoProvider option }
Provider: RepoProvider option
BaseBranch: string }

type NavAction =
| NoAction
Expand Down
6 changes: 4 additions & 2 deletions src/Server/DemoFixture.fs
Original file line number Diff line number Diff line change
Expand Up @@ -330,12 +330,14 @@ let private baseDashboard: DashboardResponse =
RootFolderName = "CloudPlatform"
Worktrees = [ wtAzDoMain; wtRetryLogic; wtConfigLoading; wtAuthMiddleware; wtArchived ]
IsReady = true
Provider = Some(AzDoProvider "https://dev.azure.com/contoso/CloudPlatform") }
Provider = Some(AzDoProvider "https://dev.azure.com/contoso/CloudPlatform")
BaseBranch = "main" }
{ RepoId = githubRepoId
RootFolderName = "DataPipeline"
Worktrees = [ wtGithubMain; wtStreaming; wtCsvFix ]
IsReady = true
Provider = Some(GitHubProvider "https://github.com/contoso/DataPipeline") } ]
Provider = Some(GitHubProvider "https://github.com/contoso/DataPipeline")
BaseBranch = "main" } ]
SchedulerEvents = baseSchedulerEvents
LatestByCategory = baseLatestByCategory
AppVersion = "demo|0"
Expand Down
19 changes: 10 additions & 9 deletions src/Server/GitWorktree.fs
Original file line number Diff line number Diff line change
Expand Up @@ -106,26 +106,26 @@ let getLastCommit (worktreePath: string) =
return parseCommitOutput worktreePath fallback
}

let private tryFastForwardMain (repoRoot: string) (mainRef: string) =
let private tryFastForwardMain (repoRoot: string) (baseBranch: string) (mainRef: string) =
async {
let! currentBranch = runGit repoRoot "rev-parse --abbrev-ref HEAD"

match currentBranch |> Option.map _.Trim() with
| Some "main" ->
| Some branch when branch = baseBranch ->
let! result = runGitResult repoRoot $"merge --ff-only {mainRef}"

match result with
| Ok _ -> Log.log "Git" $"Fast-forwarded main in {repoRoot}"
| Ok _ -> Log.log "Git" $"Fast-forwarded {baseBranch} in {repoRoot}"
| Error msg -> Log.log "Git" $"Fast-forward skipped in {repoRoot}: {msg}"
| _ -> ()
}

let mainRef (upstreamRemote: string) = $"{upstreamRemote}/main"
let mainRef (upstreamRemote: string) (baseBranch: string) = $"{upstreamRemote}/{baseBranch}"

let fetchUpstream (repoRoot: string) (upstreamRemote: string) =
let fetchUpstream (repoRoot: string) (upstreamRemote: string) (baseBranch: string) =
async {
let! _ = runGit repoRoot $"fetch {upstreamRemote} main"
do! tryFastForwardMain repoRoot (mainRef upstreamRemote)
let! _ = runGit repoRoot $"fetch {upstreamRemote} {baseBranch}"
do! tryFastForwardMain repoRoot baseBranch (mainRef upstreamRemote baseBranch)
}

let getMainBehindCount (worktreePath: string) (mainRef: string) =
Expand Down Expand Up @@ -320,9 +320,10 @@ let removeWorktree (repoRoot: string) (worktreePath: string) (branch: string opt
|> AsyncResult.ignore
}

let branchSortKey (name: string) =
let branchSortKey (baseBranch: string) (name: string) =
match name with
| "main" -> (0, name)
| n when n = baseBranch -> (0, name)
| "main" -> (1, name)
| "master" -> (1, name)
| "develop" -> (2, name)
| n when n.StartsWith("dev") -> (3, name)
Expand Down
13 changes: 11 additions & 2 deletions src/Server/RefreshScheduler.fs
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ type PerRepoState =
PrData: Map<string, PrStatus>
Provider: RepoProvider option
UpstreamRemote: string
BaseBranch: string
IsReady: bool }

module PerRepoState =
Expand All @@ -28,6 +29,7 @@ module PerRepoState =
PrData = Map.empty
Provider = None
UpstreamRemote = "origin"
BaseBranch = "main"
IsReady = false }

type DashboardState =
Expand Down Expand Up @@ -57,6 +59,7 @@ type StateMsg =
| UpdatePr of repoId: RepoId * Map<string, PrStatus>
| UpdateProvider of repoId: RepoId * RepoProvider option
| UpdateUpstreamRemote of repoId: RepoId * remote: string
| UpdateBaseBranch of repoId: RepoId * baseBranch: string
| RemoveWorktree of repoId: RepoId * path: string
| GetState of AsyncReplyChannel<DashboardState>
| LogSchedulerEvent of CardEvent
Expand Down Expand Up @@ -145,6 +148,10 @@ let private processMessage (state: DashboardState) (msg: StateMsg) =
let repo = getRepo repoId state
updateRepo repoId { repo with UpstreamRemote = remote } state

| UpdateBaseBranch(repoId, baseBranch) ->
let repo = getRepo repoId state
updateRepo repoId { repo with BaseBranch = baseBranch } state

| RemoveWorktree(repoId, path) ->
let repo = getRepo repoId state
updateRepo repoId (removeWorktreeData path repo) state
Expand Down Expand Up @@ -341,6 +348,8 @@ let private executeTask
let! upstreamRemote = GitWorktree.resolveUpstreamRemote root
agent.Post(UpdateWorktreeList(repoId, worktrees))
agent.Post(UpdateUpstreamRemote(repoId, upstreamRemote))
let baseBranch = TreemonConfig.readBaseBranch root
agent.Post(UpdateBaseBranch(repoId, baseBranch))
let! state = agent.PostAndAsyncReply(GetState)
let alreadyDetected = state.Repos |> Map.tryFind repoId |> Option.bind _.Provider |> Option.isSome
if not alreadyDetected then
Expand All @@ -351,7 +360,7 @@ let private executeTask
| RefreshGit(repoId, path) ->
let! state = agent.PostAndAsyncReply(GetState)
let repo = state.Repos |> Map.tryFind repoId |> Option.defaultValue PerRepoState.empty
let mainRef = GitWorktree.mainRef repo.UpstreamRemote
let mainRef = GitWorktree.mainRef repo.UpstreamRemote repo.BaseBranch

let branch =
repo.WorktreeList
Expand Down Expand Up @@ -387,7 +396,7 @@ let private executeTask
let root = rootPaths |> Map.find repoId
let! state = agent.PostAndAsyncReply(GetState)
let repo = state.Repos |> Map.tryFind repoId |> Option.defaultValue PerRepoState.empty
do! GitWorktree.fetchUpstream root repo.UpstreamRemote
do! GitWorktree.fetchUpstream root repo.UpstreamRemote repo.BaseBranch
}

let private timeoutMs = 60_000
Expand Down
16 changes: 8 additions & 8 deletions src/Server/SyncEngine.fs
Original file line number Diff line number Diff line change
Expand Up @@ -291,8 +291,8 @@ module private PipelineSteps =
let fetch (ctx: StepContext) (upstreamRemote: string) =
runStep ctx SyncStep.Pull "git" (buildFetchArgs upstreamRemote) checkExitCode

let merge (ctx: StepContext) (upstreamRemote: string) : Async<Result<bool, StepStatus>> =
let mergeTarget = GitWorktree.mainRef upstreamRemote
let merge (ctx: StepContext) (upstreamRemote: string) (baseBranch: string) : Async<Result<bool, StepStatus>> =
let mergeTarget = GitWorktree.mainRef upstreamRemote baseBranch
let cmdString = $"git merge {mergeTarget}"
async {
ctx.Post (UpdateProcessState (ctx.Branch, SyncState.Running SyncStep.Merge))
Expand Down Expand Up @@ -351,24 +351,24 @@ module private PipelineSteps =
let push (ctx: StepContext) =
runStep ctx SyncStep.Push "git" "push" checkExitCode

let rebase (ctx: StepContext) (upstreamRemote: string) =
let mergeTarget = GitWorktree.mainRef upstreamRemote
let rebase (ctx: StepContext) (upstreamRemote: string) (baseBranch: string) =
let mergeTarget = GitWorktree.mainRef upstreamRemote baseBranch
runStep ctx SyncStep.Rebase "git" $"rebase {mergeTarget}" checkExitCode


let executeSyncPipeline (post: SyncMsg -> unit) (branch: string) (worktreePath: string) (repoRoot: string) (provider: Shared.CodingToolProvider option) (upstreamRemote: string) (prStatus: PrStatus) (ct: CancellationToken) : Async<unit> =
let executeSyncPipeline (post: SyncMsg -> unit) (branch: string) (worktreePath: string) (repoRoot: string) (provider: Shared.CodingToolProvider option) (upstreamRemote: string) (baseBranch: string) (prStatus: PrStatus) (ct: CancellationToken) : Async<unit> =
let ctx = { Post = post; Branch = branch; WorktreePath = worktreePath; Ct = ct }

let pipeline () =
asyncResult {
do! PipelineSteps.checkClean ctx
do! PipelineSteps.fetch ctx upstreamRemote
let mainRef = GitWorktree.mainRef upstreamRemote
let mainRef = GitWorktree.mainRef upstreamRemote baseBranch
let! commitCount = GitWorktree.getCommitCount ctx.WorktreePath mainRef |> Async.map Ok
if commitCount = 0 then
do! PipelineSteps.rebase ctx upstreamRemote
do! PipelineSteps.rebase ctx upstreamRemote baseBranch
else
let! hasConflicts = PipelineSteps.merge ctx upstreamRemote
let! hasConflicts = PipelineSteps.merge ctx upstreamRemote baseBranch
if hasConflicts then
do! PipelineSteps.resolveConflicts ctx provider
do! PipelineSteps.runTests ctx repoRoot
Expand Down
10 changes: 10 additions & 0 deletions src/Server/TreemonConfig.fs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ let private configLock = obj ()
let private configPath repoRoot = Path.Combine(repoRoot, ".treemon.json")

let private validRemoteNamePattern = Regex(@"^[a-zA-Z0-9._-]+$")
let private validBranchNamePattern = Regex(@"^[a-zA-Z0-9][a-zA-Z0-9._/-]*$")

let private withJsonProperty (path: string) (propertyName: string) (onFound: JsonElement -> 'a) (defaultValue: 'a) : 'a =
if not (File.Exists(path)) then
Expand Down Expand Up @@ -90,6 +91,15 @@ let readUpstreamRemote (repoRoot: string) : string option =
Log.log "TreemonConfig" $"Rejected invalid upstreamRemote value: '{value}'"
None)

let readBaseBranch (repoRoot: string) : string =
readStringConfig repoRoot "baseBranch"
|> Option.bind (fun value ->
if validBranchNamePattern.IsMatch(value) then Some value
else
Log.log "TreemonConfig" $"Rejected invalid baseBranch value: '{value}'"
None)
|> Option.defaultValue "main"

let readTestCommand (repoRoot: string) : string option =
readStringConfig repoRoot "testCommand"

Expand Down
7 changes: 4 additions & 3 deletions src/Server/WorktreeApi.fs
Original file line number Diff line number Diff line change
Expand Up @@ -227,7 +227,8 @@ let getWorktrees
RootFolderName = Path.GetFileName(originalPath)
Worktrees = statuses
IsReady = repo.IsReady
Provider = repo.Provider })
Provider = repo.Provider
BaseBranch = repo.BaseBranch })

return
{ Repos = repos
Expand Down Expand Up @@ -399,7 +400,7 @@ let worktreeApi
let repo = state.Repos |> Map.tryFind ctx.RepoId |> Option.defaultValue RefreshScheduler.PerRepoState.empty
let upstreamBranch = repo.GitData |> Map.tryFind ctx.Worktree.Path |> Option.bind _.UpstreamBranch
let prStatus = PrStatus.lookupPrStatus repo.PrData upstreamBranch
Async.Start(SyncEngine.executeSyncPipeline post syncKey ctx.Worktree.Path ctx.RepoRoot provider repo.UpstreamRemote prStatus ct, ct)
Async.Start(SyncEngine.executeSyncPipeline post syncKey ctx.Worktree.Path ctx.RepoRoot provider repo.UpstreamRemote repo.BaseBranch prStatus ct, ct)
}
cancelSync = fun wtPath ->
let path = WorktreePath.value wtPath
Expand Down Expand Up @@ -503,7 +504,7 @@ let worktreeApi
|> Option.map (fun repo ->
repo.WorktreeList
|> List.choose _.Branch
|> List.sortBy GitWorktree.branchSortKey)
|> List.sortBy (GitWorktree.branchSortKey repo.BaseBranch))
|> Option.defaultValue []
}
createWorktree = fun req ->
Expand Down
3 changes: 2 additions & 1 deletion src/Shared/Types.fs
Original file line number Diff line number Diff line change
Expand Up @@ -152,7 +152,8 @@ type RepoWorktrees =
RootFolderName: string
Worktrees: WorktreeStatus list
IsReady: bool
Provider: RepoProvider option }
Provider: RepoProvider option
BaseBranch: string }

type SystemMetrics =
{ CpuPercent: float
Expand Down
5 changes: 3 additions & 2 deletions src/Tests/ArchiveTests.fs
Original file line number Diff line number Diff line change
Expand Up @@ -236,7 +236,8 @@ type NavigationArchiveTests() =
ArchivedWorktrees = archivedWorktrees
IsReady = true
IsCollapsed = false
Provider = None }
Provider = None
BaseBranch = "main" }

let makeWorktreeStatus branch isArchived : WorktreeStatus =
{ Path = WorktreePath $"/repo/{branch}"
Expand Down Expand Up @@ -357,7 +358,7 @@ type ArchiveE2ETests() =

let makeDashboardJson (worktrees: string list) =
let wts = worktrees |> String.concat ","
$"""{{"Repos":[{{"RepoId":{{"RepoId":"TestRepo"}},"RootFolderName":"TestRepo","Worktrees":[{wts}],"IsReady":true}}],"SchedulerEvents":[],"LatestByCategory":{{}},"AppVersion":"test","EditorName":"","CollapsedRepos":[]}}"""
$"""{{"Repos":[{{"RepoId":{{"RepoId":"TestRepo"}},"RootFolderName":"TestRepo","Worktrees":[{wts}],"IsReady":true,"BaseBranch":"main"}}],"SchedulerEvents":[],"LatestByCategory":{{}},"AppVersion":"test","EditorName":"","CollapsedRepos":[]}}"""

let emptySyncStatus = "{}"

Expand Down
3 changes: 2 additions & 1 deletion src/Tests/ConfirmModalTests.fs
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,8 @@ let private makeRepo repoId worktrees : RepoModel =
ArchivedWorktrees = []
IsReady = true
IsCollapsed = false
Provider = None }
Provider = None
BaseBranch = "main" }

let private defaultModel : Model =
{ Repos = [ makeRepo "repo" [ makeWorktree "feature-branch" true; makeWorktree "main" false ] ]
Expand Down
3 changes: 2 additions & 1 deletion src/Tests/CreateWorktreeTests.fs
Original file line number Diff line number Diff line change
Expand Up @@ -577,7 +577,8 @@ type EnterKeySuppressedWhileModalOpenTests() =
ArchivedWorktrees = []
IsReady = true
IsCollapsed = false
Provider = None }
Provider = None
BaseBranch = "main" }

let openForm =
Modal.Open { RepoId = repoId; Branches = [ "main" ]; Name = "test"; BaseBranch = "main" }
Expand Down
3 changes: 2 additions & 1 deletion src/Tests/NavigationTests.fs
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,8 @@ module NavHelpers =
ArchivedWorktrees = []
IsReady = true
IsCollapsed = false
Provider = None }
Provider = None
BaseBranch = "main" }

let scrollHint (_, _, hint) = hint

Expand Down
Loading
Loading