From f8c5f3057e96f74922cf64644e3b840e95c4e306 Mon Sep 17 00:00:00 2001 From: Petr Pokorny Date: Thu, 21 May 2026 13:00:56 +0200 Subject: [PATCH 1/8] tm-basebranch-xky Add readBaseBranch to TreemonConfig Added readBaseBranch function to TreemonConfig.fs with validation. 14 unit tests in UpstreamRemoteTests.fs ReadBaseBranchTests class. --- docs/spec/configurable-base-branch.md | 61 +++++++++++ src/Server/TreemonConfig.fs | 9 ++ src/Tests/UpstreamRemoteTests.fs | 145 ++++++++++++++++++++++++++ 3 files changed, 215 insertions(+) create mode 100644 docs/spec/configurable-base-branch.md diff --git a/docs/spec/configurable-base-branch.md b/docs/spec/configurable-base-branch.md new file mode 100644 index 0000000..2772674 --- /dev/null +++ b/docs/spec/configurable-base-branch.md @@ -0,0 +1,61 @@ +# Configurable Base Branch + +## Goals + +Allow each monitored repo to specify a "base branch" (e.g., `dev`, `develop`) instead of always assuming `main`. This affects ahead/behind counts, diff stats, fetch targets, fast-forward, merge/rebase targets, and branch sort priority. + +Config-file level support only — no UI for editing this setting. + +## Expected Behavior + +- A repo's `.treemon.json` can include `"baseBranch": "dev"`. +- When omitted, defaults to `"main"` (current behavior preserved). +- All git operations that reference the base branch use the configured value: + - `git fetch ` + - `git rev-list --count HEAD../` (behind count) + - `git rev-list --count --no-merges /..HEAD` (commit count) + - `git diff --shortstat /...HEAD` (diff stats) + - `git merge --ff-only /` (fast-forward) + - `git merge /` / `git rebase /` (sync pipeline) +- Fast-forward logic triggers when current branch matches the configured base branch (not hardcoded `"main"`). +- Branch sort priority puts the configured base branch first (priority 0). +- Deploy branch detection treats the configured base branch (and `master`) as non-deploy branches. + +## Technical Approach + +### Config Reading + +Add `readBaseBranch` to `TreemonConfig.fs` using existing `readStringConfig` helper. Returns `string` (defaulting to `"main"`). Apply the same validation pattern as `readUpstreamRemote` (alphanumeric + `._/-`). + +### State Propagation + +Add `BaseBranch: string` to `PerRepoState` in `RefreshScheduler.fs` (default `"main"`). Read it during `RefreshWorktreeList` alongside `resolveUpstreamRemote`. Add `UpdateBaseBranch` message to `StateMsg`. + +### GitWorktree Changes + +- `mainRef`: add `baseBranch` parameter → `$"{upstreamRemote}/{baseBranch}"` +- `fetchUpstream`: add `baseBranch` parameter → `fetch {upstreamRemote} {baseBranch}` +- `tryFastForwardMain`: compare current branch against `baseBranch` instead of `"main"` +- `branchSortKey`: accept `baseBranch` parameter, give it priority 0 + +### Caller Updates + +- `RefreshScheduler.fs`: pass `repo.BaseBranch` to `mainRef`, `fetchUpstream` +- `SyncEngine.fs`: accept and pass through `baseBranch` +- `WorktreeApi.fs`: pass `baseBranch` to `branchSortKey` and sync pipeline +- `Program.fs`: `readDeployBranch` — ideally reads config, but since it runs at startup before repo roots are known, keep matching `"main" | "master"` (acceptable simplification) + +### Key Files + +- `src/Server/TreemonConfig.fs` — add `readBaseBranch` +- `src/Server/RefreshScheduler.fs` — add to `PerRepoState`, state message, propagation +- `src/Server/GitWorktree.fs` — parameterize all base-branch-dependent functions +- `src/Server/SyncEngine.fs` — thread `baseBranch` through pipeline +- `src/Server/WorktreeApi.fs` — pass `baseBranch` to sort and sync calls +- `src/Tests/UpstreamRemoteTests.fs` — update `mainRef` tests for new signature + +## Decisions + +- **No rename of `MainBehindCount` / `IsMainWorktree` fields** — these are shared types used by the client. Renaming is a separate cosmetic change; the semantics (behind count relative to base branch) remain correct. +- **No rename of `mainRef` function** — keeping the name avoids a large mechanical diff. The function now takes `baseBranch` to clarify its meaning. +- **`Program.fs` deploy branch detection unchanged** — runs at server startup before any repo config is loaded; hardcoded `main/master` check is acceptable here. diff --git a/src/Server/TreemonConfig.fs b/src/Server/TreemonConfig.fs index 3edc640..5156533 100644 --- a/src/Server/TreemonConfig.fs +++ b/src/Server/TreemonConfig.fs @@ -90,6 +90,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 validRemoteNamePattern.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" diff --git a/src/Tests/UpstreamRemoteTests.fs b/src/Tests/UpstreamRemoteTests.fs index 8fce86a..9817462 100644 --- a/src/Tests/UpstreamRemoteTests.fs +++ b/src/Tests/UpstreamRemoteTests.fs @@ -153,6 +153,151 @@ type ReadUpstreamRemoteTests() = Assert.That(result, Is.EqualTo(Some "origin.backup")) +// ─── TreemonConfig: readBaseBranch ─── + +[] +[] +[] +type ReadBaseBranchTests() = + + let mutable tempDir = "" + + [] + member _.Setup() = + tempDir <- Path.Combine(Path.GetTempPath(), $"treemon-test-{Guid.NewGuid()}") + Directory.CreateDirectory(tempDir) |> ignore + + [] + member _.TearDown() = + if Directory.Exists(tempDir) then + Directory.Delete(tempDir, recursive = true) + + [] + member _.``readBaseBranch returns main when file does not exist``() = + let result = readBaseBranch tempDir + Assert.That(result, Is.EqualTo("main")) + + [] + member _.``readBaseBranch returns main when file has no baseBranch field``() = + File.WriteAllText( + Path.Combine(tempDir, ".treemon.json"), + """{ "archivedBranches": ["a"] }""") + + let result = readBaseBranch tempDir + Assert.That(result, Is.EqualTo("main")) + + [] + member _.``readBaseBranch returns configured value``() = + File.WriteAllText( + Path.Combine(tempDir, ".treemon.json"), + """{ "baseBranch": "dev" }""") + + let result = readBaseBranch tempDir + Assert.That(result, Is.EqualTo("dev")) + + [] + member _.``readBaseBranch returns develop branch name``() = + File.WriteAllText( + Path.Combine(tempDir, ".treemon.json"), + """{ "baseBranch": "develop" }""") + + let result = readBaseBranch tempDir + Assert.That(result, Is.EqualTo("develop")) + + [] + member _.``readBaseBranch returns main for empty string``() = + File.WriteAllText( + Path.Combine(tempDir, ".treemon.json"), + """{ "baseBranch": "" }""") + + let result = readBaseBranch tempDir + Assert.That(result, Is.EqualTo("main")) + + [] + member _.``readBaseBranch returns main for whitespace-only string``() = + File.WriteAllText( + Path.Combine(tempDir, ".treemon.json"), + """{ "baseBranch": " " }""") + + let result = readBaseBranch tempDir + Assert.That(result, Is.EqualTo("main")) + + [] + member _.``readBaseBranch returns main for malformed JSON``() = + File.WriteAllText( + Path.Combine(tempDir, ".treemon.json"), + """not valid json""") + + let result = readBaseBranch tempDir + Assert.That(result, Is.EqualTo("main")) + + [] + member _.``readBaseBranch returns main when baseBranch is not a string``() = + File.WriteAllText( + Path.Combine(tempDir, ".treemon.json"), + """{ "baseBranch": 42 }""") + + let result = readBaseBranch tempDir + Assert.That(result, Is.EqualTo("main")) + + [] + member _.``readBaseBranch returns main when baseBranch is null``() = + File.WriteAllText( + Path.Combine(tempDir, ".treemon.json"), + """{ "baseBranch": null }""") + + let result = readBaseBranch tempDir + Assert.That(result, Is.EqualTo("main")) + + [] + member _.``readBaseBranch rejects value with spaces``() = + File.WriteAllText( + Path.Combine(tempDir, ".treemon.json"), + """{ "baseBranch": "main; rm -rf /" }""") + + let result = readBaseBranch tempDir + Assert.That(result, Is.EqualTo("main")) + + [] + member _.``readBaseBranch rejects value starting with double dash``() = + File.WriteAllText( + Path.Combine(tempDir, ".treemon.json"), + """{ "baseBranch": "--upload-pack=evil" }""") + + let result = readBaseBranch tempDir + Assert.That(result, Is.EqualTo("main")) + + [] + member _.``readBaseBranch accepts hyphenated branch name``() = + File.WriteAllText( + Path.Combine(tempDir, ".treemon.json"), + """{ "baseBranch": "release-2.0" }""") + + let result = readBaseBranch tempDir + Assert.That(result, Is.EqualTo("release-2.0")) + + [] + member _.``readBaseBranch accepts branch with dots``() = + File.WriteAllText( + Path.Combine(tempDir, ".treemon.json"), + """{ "baseBranch": "release.2.0" }""") + + let result = readBaseBranch tempDir + Assert.That(result, Is.EqualTo("release.2.0")) + + [] + member _.``readBaseBranch coexists with other fields``() = + File.WriteAllText( + Path.Combine(tempDir, ".treemon.json"), + """{ "archivedBranches": ["old"], "baseBranch": "dev", "upstreamRemote": "upstream" }""") + + let result = readBaseBranch tempDir + Assert.That(result, Is.EqualTo("dev")) + + let remote = readUpstreamRemote tempDir + Assert.That(remote, Is.EqualTo(Some "upstream"), "Other fields should still be readable") + + // ─── Git command construction: mainRef, fetch, merge targets ─── [] From e991927582262793a162208c6582f9974edf71e3 Mon Sep 17 00:00:00 2001 From: Petr Pokorny Date: Thu, 21 May 2026 13:06:01 +0200 Subject: [PATCH 2/8] tm-basebranch-bgm Parameterize GitWorktree functions for base branch Parameterized mainRef, fetchUpstream, tryFastForwardMain, branchSortKey in GitWorktree.fs with baseBranch parameter. Updated callers in RefreshScheduler.fs, SyncEngine.fs, WorktreeApi.fs to pass 'main' default. Added mainRef and branchSortKey tests. --- src/Server/GitWorktree.fs | 19 ++++++++-------- src/Server/RefreshScheduler.fs | 4 ++-- src/Server/SyncEngine.fs | 14 ++++++------ src/Server/WorktreeApi.fs | 2 +- src/Tests/UpstreamRemoteTests.fs | 38 +++++++++++++++++++++++++++++--- 5 files changed, 55 insertions(+), 22 deletions(-) diff --git a/src/Server/GitWorktree.fs b/src/Server/GitWorktree.fs index 84caaaa..4a3f8d4 100644 --- a/src/Server/GitWorktree.fs +++ b/src/Server/GitWorktree.fs @@ -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) = @@ -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) diff --git a/src/Server/RefreshScheduler.fs b/src/Server/RefreshScheduler.fs index dc50567..c4ba21e 100644 --- a/src/Server/RefreshScheduler.fs +++ b/src/Server/RefreshScheduler.fs @@ -351,7 +351,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 "main" let branch = repo.WorktreeList @@ -387,7 +387,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 "main" } let private timeoutMs = 60_000 diff --git a/src/Server/SyncEngine.fs b/src/Server/SyncEngine.fs index 6ba7385..785f4be 100644 --- a/src/Server/SyncEngine.fs +++ b/src/Server/SyncEngine.fs @@ -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> = - let mergeTarget = GitWorktree.mainRef upstreamRemote + let merge (ctx: StepContext) (upstreamRemote: string) (baseBranch: string) : Async> = + let mergeTarget = GitWorktree.mainRef upstreamRemote baseBranch let cmdString = $"git merge {mergeTarget}" async { ctx.Post (UpdateProcessState (ctx.Branch, SyncState.Running SyncStep.Merge)) @@ -351,8 +351,8 @@ 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 @@ -363,12 +363,12 @@ let executeSyncPipeline (post: SyncMsg -> unit) (branch: string) (worktreePath: asyncResult { do! PipelineSteps.checkClean ctx do! PipelineSteps.fetch ctx upstreamRemote - let mainRef = GitWorktree.mainRef upstreamRemote + let mainRef = GitWorktree.mainRef upstreamRemote "main" let! commitCount = GitWorktree.getCommitCount ctx.WorktreePath mainRef |> Async.map Ok if commitCount = 0 then - do! PipelineSteps.rebase ctx upstreamRemote + do! PipelineSteps.rebase ctx upstreamRemote "main" else - let! hasConflicts = PipelineSteps.merge ctx upstreamRemote + let! hasConflicts = PipelineSteps.merge ctx upstreamRemote "main" if hasConflicts then do! PipelineSteps.resolveConflicts ctx provider do! PipelineSteps.runTests ctx repoRoot diff --git a/src/Server/WorktreeApi.fs b/src/Server/WorktreeApi.fs index 7dc96f4..7f509d3 100644 --- a/src/Server/WorktreeApi.fs +++ b/src/Server/WorktreeApi.fs @@ -503,7 +503,7 @@ let worktreeApi |> Option.map (fun repo -> repo.WorktreeList |> List.choose _.Branch - |> List.sortBy GitWorktree.branchSortKey) + |> List.sortBy (GitWorktree.branchSortKey "main")) |> Option.defaultValue [] } createWorktree = fun req -> diff --git a/src/Tests/UpstreamRemoteTests.fs b/src/Tests/UpstreamRemoteTests.fs index 9817462..d99c928 100644 --- a/src/Tests/UpstreamRemoteTests.fs +++ b/src/Tests/UpstreamRemoteTests.fs @@ -307,15 +307,23 @@ type GitCommandConstructionTests() = [] member _.``mainRef with origin produces origin/main``() = - Assert.That(mainRef "origin", Is.EqualTo("origin/main")) + Assert.That(mainRef "origin" "main", Is.EqualTo("origin/main")) [] member _.``mainRef with upstream produces upstream/main``() = - Assert.That(mainRef "upstream", Is.EqualTo("upstream/main")) + Assert.That(mainRef "upstream" "main", Is.EqualTo("upstream/main")) [] member _.``mainRef with custom remote produces custom/main``() = - Assert.That(mainRef "my-fork", Is.EqualTo("my-fork/main")) + Assert.That(mainRef "my-fork" "main", Is.EqualTo("my-fork/main")) + + [] + member _.``mainRef with custom baseBranch produces remote/baseBranch``() = + Assert.That(mainRef "origin" "dev", Is.EqualTo("origin/dev")) + + [] + member _.``mainRef with upstream and develop produces upstream/develop``() = + Assert.That(mainRef "upstream" "develop", Is.EqualTo("upstream/develop")) [] member _.``SyncEngine buildFetchArgs with origin produces fetch origin``() = @@ -345,6 +353,30 @@ type GitCommandConstructionTests() = Assert.That(args, Does.Contain("Q:\\code\\myproject")) Assert.That(args, Does.Contain("remote get-url upstream")) + [] + member _.``branchSortKey gives configured baseBranch priority 0``() = + Assert.That(branchSortKey "dev" "dev", Is.EqualTo((0, "dev"))) + + [] + member _.``branchSortKey gives main priority 0 when baseBranch is main``() = + Assert.That(branchSortKey "main" "main", Is.EqualTo((0, "main"))) + + [] + member _.``branchSortKey gives main priority 1 when baseBranch is not main``() = + Assert.That(branchSortKey "dev" "main", Is.EqualTo((1, "main"))) + + [] + member _.``branchSortKey gives master priority 1``() = + Assert.That(branchSortKey "dev" "master", Is.EqualTo((1, "master"))) + + [] + member _.``branchSortKey gives develop priority 2 when not baseBranch``() = + Assert.That(branchSortKey "main" "develop", Is.EqualTo((2, "develop"))) + + [] + member _.``branchSortKey gives feature branch priority 4``() = + Assert.That(branchSortKey "main" "feature/xyz", Is.EqualTo((4, "feature/xyz"))) + // ─── RefreshScheduler: PerRepoState defaults and UpdateUpstreamRemote ─── From ad31b1645c0a86f439b63ae198fe0ce8eead3793 Mon Sep 17 00:00:00 2001 From: Petr Pokorny Date: Thu, 21 May 2026 13:11:07 +0200 Subject: [PATCH 3/8] [tm-basebranch-g8e] Thread baseBranch through state and callers Added BaseBranch field to PerRepoState + UpdateBaseBranch StateMsg in RefreshScheduler.fs. Read config via TreemonConfig.readBaseBranch during RefreshWorktreeList. Passed repo.BaseBranch to all mainRef/fetchUpstream/branchSortKey/executeSyncPipeline callers. --- src/Server/RefreshScheduler.fs | 13 +++++++++++-- src/Server/SyncEngine.fs | 8 ++++---- src/Server/WorktreeApi.fs | 4 ++-- 3 files changed, 17 insertions(+), 8 deletions(-) diff --git a/src/Server/RefreshScheduler.fs b/src/Server/RefreshScheduler.fs index c4ba21e..d8c1e0e 100644 --- a/src/Server/RefreshScheduler.fs +++ b/src/Server/RefreshScheduler.fs @@ -16,6 +16,7 @@ type PerRepoState = PrData: Map Provider: RepoProvider option UpstreamRemote: string + BaseBranch: string IsReady: bool } module PerRepoState = @@ -28,6 +29,7 @@ module PerRepoState = PrData = Map.empty Provider = None UpstreamRemote = "origin" + BaseBranch = "main" IsReady = false } type DashboardState = @@ -57,6 +59,7 @@ type StateMsg = | UpdatePr of repoId: RepoId * Map | 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 | LogSchedulerEvent of CardEvent @@ -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 @@ -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 @@ -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 "main" + let mainRef = GitWorktree.mainRef repo.UpstreamRemote repo.BaseBranch let branch = repo.WorktreeList @@ -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 "main" + do! GitWorktree.fetchUpstream root repo.UpstreamRemote repo.BaseBranch } let private timeoutMs = 60_000 diff --git a/src/Server/SyncEngine.fs b/src/Server/SyncEngine.fs index 785f4be..bea62fd 100644 --- a/src/Server/SyncEngine.fs +++ b/src/Server/SyncEngine.fs @@ -356,19 +356,19 @@ module private PipelineSteps = 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 = +let executeSyncPipeline (post: SyncMsg -> unit) (branch: string) (worktreePath: string) (repoRoot: string) (provider: Shared.CodingToolProvider option) (upstreamRemote: string) (baseBranch: string) (prStatus: PrStatus) (ct: CancellationToken) : Async = 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 "main" + 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 "main" + do! PipelineSteps.rebase ctx upstreamRemote baseBranch else - let! hasConflicts = PipelineSteps.merge ctx upstreamRemote "main" + let! hasConflicts = PipelineSteps.merge ctx upstreamRemote baseBranch if hasConflicts then do! PipelineSteps.resolveConflicts ctx provider do! PipelineSteps.runTests ctx repoRoot diff --git a/src/Server/WorktreeApi.fs b/src/Server/WorktreeApi.fs index 7f509d3..e285f7a 100644 --- a/src/Server/WorktreeApi.fs +++ b/src/Server/WorktreeApi.fs @@ -399,7 +399,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 @@ -503,7 +503,7 @@ let worktreeApi |> Option.map (fun repo -> repo.WorktreeList |> List.choose _.Branch - |> List.sortBy (GitWorktree.branchSortKey "main")) + |> List.sortBy (GitWorktree.branchSortKey repo.BaseBranch)) |> Option.defaultValue [] } createWorktree = fun req -> From 6ac4cf369186453573d179dc2e33cff38432a464 Mon Sep 17 00:00:00 2001 From: Petr Pokorny Date: Thu, 21 May 2026 13:39:10 +0200 Subject: [PATCH 4/8] tm-basebranch-9nz.2 Fix baseBranch validation: allow / in branch names and reject leading dashes Add validBranchNamePattern that requires alphanumeric first char and allows slashes. Use it in readBaseBranch instead of validRemoteNamePattern. Add 4 tests: slash branches accepted, dash-prefixed branches rejected. --- src/Server/TreemonConfig.fs | 3 ++- src/Tests/UpstreamRemoteTests.fs | 36 ++++++++++++++++++++++++++++++++ 2 files changed, 38 insertions(+), 1 deletion(-) diff --git a/src/Server/TreemonConfig.fs b/src/Server/TreemonConfig.fs index 5156533..e77fa69 100644 --- a/src/Server/TreemonConfig.fs +++ b/src/Server/TreemonConfig.fs @@ -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 @@ -93,7 +94,7 @@ let readUpstreamRemote (repoRoot: string) : string option = let readBaseBranch (repoRoot: string) : string = readStringConfig repoRoot "baseBranch" |> Option.bind (fun value -> - if validRemoteNamePattern.IsMatch(value) then Some value + if validBranchNamePattern.IsMatch(value) then Some value else Log.log "TreemonConfig" $"Rejected invalid baseBranch value: '{value}'" None) diff --git a/src/Tests/UpstreamRemoteTests.fs b/src/Tests/UpstreamRemoteTests.fs index d99c928..e2fe675 100644 --- a/src/Tests/UpstreamRemoteTests.fs +++ b/src/Tests/UpstreamRemoteTests.fs @@ -297,6 +297,42 @@ type ReadBaseBranchTests() = let remote = readUpstreamRemote tempDir Assert.That(remote, Is.EqualTo(Some "upstream"), "Other fields should still be readable") + [] + member _.``readBaseBranch accepts slash-based branch name``() = + File.WriteAllText( + Path.Combine(tempDir, ".treemon.json"), + """{ "baseBranch": "release/2026.05" }""") + + let result = readBaseBranch tempDir + Assert.That(result, Is.EqualTo("release/2026.05")) + + [] + member _.``readBaseBranch accepts nested slash branch name``() = + File.WriteAllText( + Path.Combine(tempDir, ".treemon.json"), + """{ "baseBranch": "feature/team/auth" }""") + + let result = readBaseBranch tempDir + Assert.That(result, Is.EqualTo("feature/team/auth")) + + [] + member _.``readBaseBranch rejects --all flag injection``() = + File.WriteAllText( + Path.Combine(tempDir, ".treemon.json"), + """{ "baseBranch": "--all" }""") + + let result = readBaseBranch tempDir + Assert.That(result, Is.EqualTo("main")) + + [] + member _.``readBaseBranch rejects single dash prefix``() = + File.WriteAllText( + Path.Combine(tempDir, ".treemon.json"), + """{ "baseBranch": "-branch" }""") + + let result = readBaseBranch tempDir + Assert.That(result, Is.EqualTo("main")) + // ─── Git command construction: mainRef, fetch, merge targets ─── From 007e9bd09cd1af20e9b64abe5e5aa4384e22f817 Mon Sep 17 00:00:00 2001 From: Petr Pokorny Date: Thu, 21 May 2026 13:41:24 +0200 Subject: [PATCH 5/8] tm-basebranch-9nz.3 Update worktree-monitor.md spec for configurable baseBranch Fix stale reference to hardcoded {remote}/main, add Base Branch Resolution section. --- docs/spec/worktree-monitor.md | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/docs/spec/worktree-monitor.md b/docs/spec/worktree-monitor.md index 3b2615d..a7e7770 100644 --- a/docs/spec/worktree-monitor.md +++ b/docs/spec/worktree-monitor.md @@ -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) From a857e9875d6864d55516c1baf48541a45697b1ed Mon Sep 17 00:00:00 2001 From: Petr Pokorny Date: Thu, 21 May 2026 13:58:32 +0200 Subject: [PATCH 6/8] Show base branch badge on repo header when not main Add BaseBranch to RepoWorktrees and RepoModel types, populate from PerRepoState, and render a deploy-branch badge on the repo section header when the configured base branch differs from main. --- src/Client/App.fs | 5 ++++- src/Client/Navigation.fs | 3 ++- src/Server/DemoFixture.fs | 6 ++++-- src/Server/WorktreeApi.fs | 3 ++- src/Shared/Types.fs | 3 ++- src/Tests/ArchiveTests.fs | 3 ++- src/Tests/ConfirmModalTests.fs | 3 ++- src/Tests/CreateWorktreeTests.fs | 3 ++- src/Tests/NavigationTests.fs | 3 ++- 9 files changed, 22 insertions(+), 10 deletions(-) diff --git a/src/Client/App.fs b/src/Client/App.fs index caf69fc..34fa676 100644 --- a/src/Client/App.fs +++ b/src/Client/App.fs @@ -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 @@ -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" diff --git a/src/Client/Navigation.fs b/src/Client/Navigation.fs index 207bede..4529c6f 100644 --- a/src/Client/Navigation.fs +++ b/src/Client/Navigation.fs @@ -15,7 +15,8 @@ type RepoModel = ArchivedWorktrees: WorktreeStatus list IsReady: bool IsCollapsed: bool - Provider: RepoProvider option } + Provider: RepoProvider option + BaseBranch: string } type NavAction = | NoAction diff --git a/src/Server/DemoFixture.fs b/src/Server/DemoFixture.fs index 7c010ef..b2dca6d 100644 --- a/src/Server/DemoFixture.fs +++ b/src/Server/DemoFixture.fs @@ -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" diff --git a/src/Server/WorktreeApi.fs b/src/Server/WorktreeApi.fs index e285f7a..37564e4 100644 --- a/src/Server/WorktreeApi.fs +++ b/src/Server/WorktreeApi.fs @@ -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 diff --git a/src/Shared/Types.fs b/src/Shared/Types.fs index 62ca591..380c267 100644 --- a/src/Shared/Types.fs +++ b/src/Shared/Types.fs @@ -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 diff --git a/src/Tests/ArchiveTests.fs b/src/Tests/ArchiveTests.fs index 781598b..251b295 100644 --- a/src/Tests/ArchiveTests.fs +++ b/src/Tests/ArchiveTests.fs @@ -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}" diff --git a/src/Tests/ConfirmModalTests.fs b/src/Tests/ConfirmModalTests.fs index 342c016..97e465d 100644 --- a/src/Tests/ConfirmModalTests.fs +++ b/src/Tests/ConfirmModalTests.fs @@ -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 ] ] diff --git a/src/Tests/CreateWorktreeTests.fs b/src/Tests/CreateWorktreeTests.fs index fb6ce71..def5353 100644 --- a/src/Tests/CreateWorktreeTests.fs +++ b/src/Tests/CreateWorktreeTests.fs @@ -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" } diff --git a/src/Tests/NavigationTests.fs b/src/Tests/NavigationTests.fs index 0266442..dc58b86 100644 --- a/src/Tests/NavigationTests.fs +++ b/src/Tests/NavigationTests.fs @@ -31,7 +31,8 @@ module NavHelpers = ArchivedWorktrees = [] IsReady = true IsCollapsed = false - Provider = None } + Provider = None + BaseBranch = "main" } let scrollHint (_, _, hint) = hint From 0dbe0346383db0d3a46dbd2dd10368d49118dc97 Mon Sep 17 00:00:00 2001 From: Petr Pokorny Date: Thu, 21 May 2026 14:25:20 +0200 Subject: [PATCH 7/8] Fix E2E archive tests: add BaseBranch to mocked JSON response --- src/Tests/ArchiveTests.fs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Tests/ArchiveTests.fs b/src/Tests/ArchiveTests.fs index 251b295..88a2e5b 100644 --- a/src/Tests/ArchiveTests.fs +++ b/src/Tests/ArchiveTests.fs @@ -358,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 = "{}" From e8a393d230bceffb7c1cae668c5b8af18f41cbbf Mon Sep 17 00:00:00 2001 From: Petr Pokorny Date: Thu, 21 May 2026 14:25:47 +0200 Subject: [PATCH 8/8] Remove standalone base branch spec All useful content is already in worktree-monitor.md Base Branch Resolution subsection. --- docs/spec/configurable-base-branch.md | 61 --------------------------- 1 file changed, 61 deletions(-) delete mode 100644 docs/spec/configurable-base-branch.md diff --git a/docs/spec/configurable-base-branch.md b/docs/spec/configurable-base-branch.md deleted file mode 100644 index 2772674..0000000 --- a/docs/spec/configurable-base-branch.md +++ /dev/null @@ -1,61 +0,0 @@ -# Configurable Base Branch - -## Goals - -Allow each monitored repo to specify a "base branch" (e.g., `dev`, `develop`) instead of always assuming `main`. This affects ahead/behind counts, diff stats, fetch targets, fast-forward, merge/rebase targets, and branch sort priority. - -Config-file level support only — no UI for editing this setting. - -## Expected Behavior - -- A repo's `.treemon.json` can include `"baseBranch": "dev"`. -- When omitted, defaults to `"main"` (current behavior preserved). -- All git operations that reference the base branch use the configured value: - - `git fetch ` - - `git rev-list --count HEAD../` (behind count) - - `git rev-list --count --no-merges /..HEAD` (commit count) - - `git diff --shortstat /...HEAD` (diff stats) - - `git merge --ff-only /` (fast-forward) - - `git merge /` / `git rebase /` (sync pipeline) -- Fast-forward logic triggers when current branch matches the configured base branch (not hardcoded `"main"`). -- Branch sort priority puts the configured base branch first (priority 0). -- Deploy branch detection treats the configured base branch (and `master`) as non-deploy branches. - -## Technical Approach - -### Config Reading - -Add `readBaseBranch` to `TreemonConfig.fs` using existing `readStringConfig` helper. Returns `string` (defaulting to `"main"`). Apply the same validation pattern as `readUpstreamRemote` (alphanumeric + `._/-`). - -### State Propagation - -Add `BaseBranch: string` to `PerRepoState` in `RefreshScheduler.fs` (default `"main"`). Read it during `RefreshWorktreeList` alongside `resolveUpstreamRemote`. Add `UpdateBaseBranch` message to `StateMsg`. - -### GitWorktree Changes - -- `mainRef`: add `baseBranch` parameter → `$"{upstreamRemote}/{baseBranch}"` -- `fetchUpstream`: add `baseBranch` parameter → `fetch {upstreamRemote} {baseBranch}` -- `tryFastForwardMain`: compare current branch against `baseBranch` instead of `"main"` -- `branchSortKey`: accept `baseBranch` parameter, give it priority 0 - -### Caller Updates - -- `RefreshScheduler.fs`: pass `repo.BaseBranch` to `mainRef`, `fetchUpstream` -- `SyncEngine.fs`: accept and pass through `baseBranch` -- `WorktreeApi.fs`: pass `baseBranch` to `branchSortKey` and sync pipeline -- `Program.fs`: `readDeployBranch` — ideally reads config, but since it runs at startup before repo roots are known, keep matching `"main" | "master"` (acceptable simplification) - -### Key Files - -- `src/Server/TreemonConfig.fs` — add `readBaseBranch` -- `src/Server/RefreshScheduler.fs` — add to `PerRepoState`, state message, propagation -- `src/Server/GitWorktree.fs` — parameterize all base-branch-dependent functions -- `src/Server/SyncEngine.fs` — thread `baseBranch` through pipeline -- `src/Server/WorktreeApi.fs` — pass `baseBranch` to sort and sync calls -- `src/Tests/UpstreamRemoteTests.fs` — update `mainRef` tests for new signature - -## Decisions - -- **No rename of `MainBehindCount` / `IsMainWorktree` fields** — these are shared types used by the client. Renaming is a separate cosmetic change; the semantics (behind count relative to base branch) remain correct. -- **No rename of `mainRef` function** — keeping the name avoids a large mechanical diff. The function now takes `baseBranch` to clarify its meaning. -- **`Program.fs` deploy branch detection unchanged** — runs at server startup before any repo config is loaded; hardcoded `main/master` check is acceptable here.