mirror of
https://github.com/browseros-ai/BrowserOS.git
synced 2026-05-13 15:46:22 +00:00
feat: add workspace-centric bdev cli (#585)
* fix: clean-up bdev * feat: add workspace-centric bdev cli * fix: address review comments for 0326-bdev_cli_redesign * fix: address review feedback for PR #585 * fix: address review feedback for PR #585
This commit is contained in:
5
packages/browseros/tools/bdev/.gitignore
vendored
5
packages/browseros/tools/bdev/.gitignore
vendored
@@ -1,5 +0,0 @@
|
||||
bros
|
||||
bros-linux-amd64
|
||||
bros-linux-arm64
|
||||
bros-darwin-amd64
|
||||
bros-darwin-arm64
|
||||
@@ -1,27 +1,27 @@
|
||||
BINARY := bdev
|
||||
VERSION := $(shell git describe --tags --always --dirty 2>/dev/null || echo "dev")
|
||||
LDFLAGS := -ldflags "-X main.version=$(VERSION)"
|
||||
PREFIX ?= /usr/local/bin
|
||||
VERSION ?= dev
|
||||
|
||||
.PHONY: build install clean test
|
||||
.PHONY: build install clean test fmt
|
||||
|
||||
build:
|
||||
go build $(LDFLAGS) -o $(BINARY) .
|
||||
go build -ldflags "-X github.com/browseros-ai/BrowserOS/packages/browseros/tools/bdev/cmd.Version=$(VERSION)" -o $(BINARY) .
|
||||
|
||||
install:
|
||||
go install $(LDFLAGS) .
|
||||
|
||||
clean:
|
||||
rm -f $(BINARY)
|
||||
install: build
|
||||
mkdir -p $(PREFIX)
|
||||
cp $(BINARY) $(PREFIX)/$(BINARY)
|
||||
ifneq ($(shell uname -s),Darwin)
|
||||
@echo "Skipping codesign on non-macOS host"
|
||||
else
|
||||
codesign --force --sign - $(PREFIX)/$(BINARY)
|
||||
endif
|
||||
@echo "Installed $(BINARY) to $(PREFIX)/$(BINARY)"
|
||||
|
||||
test:
|
||||
go test ./...
|
||||
|
||||
build-linux:
|
||||
GOOS=linux GOARCH=amd64 go build $(LDFLAGS) -o $(BINARY)-linux-amd64 .
|
||||
fmt:
|
||||
gofmt -w $$(find . -name '*.go' -not -path './vendor/*')
|
||||
|
||||
build-linux-arm:
|
||||
GOOS=linux GOARCH=arm64 go build $(LDFLAGS) -o $(BINARY)-linux-arm64 .
|
||||
|
||||
build-darwin:
|
||||
GOOS=darwin GOARCH=amd64 go build $(LDFLAGS) -o $(BINARY)-darwin-amd64 .
|
||||
GOOS=darwin GOARCH=arm64 go build $(LDFLAGS) -o $(BINARY)-darwin-arm64 .
|
||||
clean:
|
||||
rm -f $(BINARY)
|
||||
|
||||
32
packages/browseros/tools/bdev/cmd/abort.go
Normal file
32
packages/browseros/tools/bdev/cmd/abort.go
Normal file
@@ -0,0 +1,32 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/browseros-ai/BrowserOS/packages/browseros/tools/bdev/internal/engine"
|
||||
"github.com/browseros-ai/BrowserOS/packages/browseros/tools/bdev/internal/resolve"
|
||||
"github.com/browseros-ai/BrowserOS/packages/browseros/tools/bdev/internal/ui"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func init() {
|
||||
command := &cobra.Command{
|
||||
Use: "abort",
|
||||
Annotations: map[string]string{"group": "Conflict:"},
|
||||
Short: "Abort conflict resolution and roll the pending files back",
|
||||
Args: cobra.NoArgs,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
ws, err := resolve.FindActive(appState.Registry, appState.CWD)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := engine.Abort(cmd.Context(), ws); err != nil {
|
||||
return err
|
||||
}
|
||||
return renderResult(map[string]any{"workspace": ws.Name, "aborted": true}, func() {
|
||||
fmt.Println(ui.Warning(fmt.Sprintf("Aborted conflict resolution for %s", ws.Name)))
|
||||
})
|
||||
},
|
||||
}
|
||||
rootCmd.AddCommand(command)
|
||||
}
|
||||
42
packages/browseros/tools/bdev/cmd/add.go
Normal file
42
packages/browseros/tools/bdev/cmd/add.go
Normal file
@@ -0,0 +1,42 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/browseros-ai/BrowserOS/packages/browseros/tools/bdev/internal/ui"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func init() {
|
||||
var patchesRepo string
|
||||
command := &cobra.Command{
|
||||
Use: "add <name> <path>",
|
||||
Aliases: []string{"register"},
|
||||
Annotations: map[string]string{"group": "Workspace:"},
|
||||
Short: "Register a Chromium checkout as a workspace",
|
||||
Args: cobra.ExactArgs(2),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if err := ensureRepoConfigured(patchesRepo); err != nil {
|
||||
return err
|
||||
}
|
||||
entry, err := appState.Registry.Add(args[0], args[1])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := appState.Save(); err != nil {
|
||||
return err
|
||||
}
|
||||
return renderResult(map[string]any{
|
||||
"workspace": entry,
|
||||
"patches_repo": appState.Config.PatchesRepo,
|
||||
}, func() {
|
||||
fmt.Println(ui.Success("Registered workspace"))
|
||||
fmt.Printf("%s %s\n", ui.Muted("name:"), entry.Name)
|
||||
fmt.Printf("%s %s\n", ui.Muted("path:"), entry.Path)
|
||||
fmt.Printf("%s %s\n", ui.Muted("repo:"), appState.Config.PatchesRepo)
|
||||
})
|
||||
},
|
||||
}
|
||||
command.Flags().StringVar(&patchesRepo, "patches-repo", "", "Path to packages/browseros")
|
||||
rootCmd.AddCommand(command)
|
||||
}
|
||||
65
packages/browseros/tools/bdev/cmd/apply.go
Normal file
65
packages/browseros/tools/bdev/cmd/apply.go
Normal file
@@ -0,0 +1,65 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/browseros-ai/BrowserOS/packages/browseros/tools/bdev/internal/engine"
|
||||
"github.com/browseros-ai/BrowserOS/packages/browseros/tools/bdev/internal/ui"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func init() {
|
||||
var src string
|
||||
var reset bool
|
||||
var changed string
|
||||
var rangeEnd string
|
||||
command := &cobra.Command{
|
||||
Use: "apply [workspace] [-- files...]",
|
||||
Annotations: map[string]string{"group": "Core:"},
|
||||
Short: "Apply repo patches to a workspace",
|
||||
Args: cobra.ArbitraryArgs,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
positional, filters := splitWorkspaceAndFilters(cmd, args)
|
||||
if len(positional) > 1 {
|
||||
return fmt.Errorf("expected at most one workspace name")
|
||||
}
|
||||
ws, err := resolveWorkspace(positional, src)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
info, err := repoInfo()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
result, err := engine.Apply(cmd.Context(), engine.ApplyOptions{
|
||||
Workspace: ws,
|
||||
Repo: info,
|
||||
Reset: reset,
|
||||
ChangedRef: changed,
|
||||
RangeEnd: rangeEnd,
|
||||
Filters: filters,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return renderResult(result, func() {
|
||||
fmt.Println(ui.Title(fmt.Sprintf("Applied patches to %s", ws.Name)))
|
||||
fmt.Printf("%s %s\n", ui.Muted("mode:"), result.Mode)
|
||||
fmt.Printf("%s %d\n", ui.Muted("applied:"), len(result.Applied))
|
||||
fmt.Printf("%s %d\n", ui.Muted("orphaned:"), len(result.Orphaned))
|
||||
if len(result.Conflicts) > 0 {
|
||||
fmt.Println(ui.Warning("Conflicts detected"))
|
||||
for _, conflict := range result.Conflicts {
|
||||
fmt.Printf(" %s\n", conflict.ChromiumPath)
|
||||
}
|
||||
fmt.Println(ui.Hint(`Run "bdev continue" after fixing the current conflict.`))
|
||||
}
|
||||
})
|
||||
},
|
||||
}
|
||||
command.Flags().StringVar(&src, "src", "", "Chromium checkout path to operate on directly")
|
||||
command.Flags().BoolVar(&reset, "reset", false, "Reset patched files to BASE_COMMIT before applying")
|
||||
command.Flags().StringVar(&changed, "changed", "", "Apply only patches changed in the given repo commit")
|
||||
command.Flags().StringVar(&rangeEnd, "range-end", "", "End revision when using --changed as a range start")
|
||||
rootCmd.AddCommand(command)
|
||||
}
|
||||
@@ -1,151 +0,0 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"bdev/internal/config"
|
||||
"bdev/internal/engine"
|
||||
"bdev/internal/git"
|
||||
"bdev/internal/log"
|
||||
"bdev/internal/ui"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var cloneCmd = &cobra.Command{
|
||||
Use: "clone",
|
||||
Short: "Fresh-apply all patches (for CI/new checkouts)",
|
||||
Long: `Apply all patches from the patches repository onto the current
|
||||
Chromium checkout. Used for CI builds and new checkout setup.
|
||||
Unlike pull, clone does not compare existing state — it applies everything.`,
|
||||
RunE: runClone,
|
||||
}
|
||||
|
||||
var (
|
||||
clonePatchesRepo string
|
||||
cloneVerifyBase bool
|
||||
cloneClean bool
|
||||
cloneDryRun bool
|
||||
cloneName string
|
||||
)
|
||||
|
||||
func init() {
|
||||
cloneCmd.Flags().StringVar(&clonePatchesRepo, "patches-repo", "", "path to BrowserOS packages directory")
|
||||
cloneCmd.Flags().BoolVar(&cloneVerifyBase, "verify-base", false, "fail if HEAD != BASE_COMMIT")
|
||||
cloneCmd.Flags().BoolVar(&cloneClean, "clean", false, "reset all modified files to BASE before applying")
|
||||
cloneCmd.Flags().BoolVar(&cloneDryRun, "dry-run", false, "show what would be applied")
|
||||
cloneCmd.Flags().StringVar(&cloneName, "name", "", "checkout name (default: directory name)")
|
||||
rootCmd.AddCommand(cloneCmd)
|
||||
}
|
||||
|
||||
func runClone(cmd *cobra.Command, args []string) error {
|
||||
cwd, err := os.Getwd()
|
||||
if err != nil {
|
||||
return fmt.Errorf("getting cwd: %w", err)
|
||||
}
|
||||
|
||||
// Try loading existing context, or create one from flags
|
||||
ctx, err := config.LoadContext()
|
||||
if err != nil {
|
||||
// No existing .bros/ — need --patches-repo
|
||||
if clonePatchesRepo == "" {
|
||||
return fmt.Errorf("no .bros/ found and --patches-repo not specified")
|
||||
}
|
||||
|
||||
patchesRepo, err := filepath.Abs(clonePatchesRepo)
|
||||
if err != nil {
|
||||
return fmt.Errorf("resolving patches repo: %w", err)
|
||||
}
|
||||
|
||||
baseCommit, err := config.ReadBaseCommit(patchesRepo)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
name := cloneName
|
||||
if name == "" {
|
||||
name = filepath.Base(cwd)
|
||||
}
|
||||
|
||||
brosDir := filepath.Join(cwd, config.BrosDirName)
|
||||
cfg := &config.Config{
|
||||
Name: name,
|
||||
PatchesRepo: patchesRepo,
|
||||
}
|
||||
if !cloneDryRun {
|
||||
if err := config.WriteConfig(brosDir, cfg); err != nil {
|
||||
return err
|
||||
}
|
||||
_ = os.MkdirAll(filepath.Join(brosDir, "logs"), 0o755)
|
||||
}
|
||||
|
||||
chromiumVersion, _ := config.ReadChromiumVersion(patchesRepo)
|
||||
ctx = &config.Context{
|
||||
Config: cfg,
|
||||
State: &config.State{},
|
||||
ChromiumDir: cwd,
|
||||
BrosDir: brosDir,
|
||||
PatchesRepo: patchesRepo,
|
||||
PatchesDir: filepath.Join(patchesRepo, "chromium_patches"),
|
||||
BaseCommit: baseCommit,
|
||||
ChromiumVersion: chromiumVersion,
|
||||
}
|
||||
}
|
||||
|
||||
if cloneDryRun {
|
||||
fmt.Println(ui.MutedStyle.Render("dry run — no files will be modified"))
|
||||
fmt.Println()
|
||||
}
|
||||
|
||||
opts := engine.CloneOpts{
|
||||
VerifyBase: cloneVerifyBase,
|
||||
Clean: cloneClean,
|
||||
DryRun: cloneDryRun,
|
||||
}
|
||||
|
||||
result, err := engine.Clone(ctx, opts)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Reuse pull rendering
|
||||
fmt.Println(ui.TitleStyle.Render("bdev clone"))
|
||||
fmt.Println()
|
||||
fmt.Printf(" %s %d patches applied\n",
|
||||
ui.SuccessStyle.Render("+"), len(result.Applied))
|
||||
if len(result.Conflicts) > 0 {
|
||||
fmt.Printf(" %s %d conflicts\n",
|
||||
ui.ErrorStyle.Render("x"), len(result.Conflicts))
|
||||
}
|
||||
if len(result.Deleted) > 0 {
|
||||
fmt.Printf(" %s %d files deleted\n",
|
||||
ui.DeletedPrefix, len(result.Deleted))
|
||||
}
|
||||
|
||||
if len(result.Conflicts) > 0 {
|
||||
fmt.Print(ui.RenderConflictReport(result.Conflicts))
|
||||
}
|
||||
|
||||
if !cloneDryRun {
|
||||
repoRev, _ := git.HeadRev(ctx.PatchesRepo)
|
||||
ctx.State.LastPull = &config.SyncEvent{
|
||||
PatchesRepoRev: repoRev,
|
||||
BaseCommit: ctx.BaseCommit,
|
||||
Timestamp: time.Now(),
|
||||
FileCount: len(result.Applied) + len(result.Deleted),
|
||||
}
|
||||
_ = config.WriteState(ctx.BrosDir, ctx.State)
|
||||
|
||||
logger := log.New(ctx.BrosDir)
|
||||
_ = logger.LogClone(ctx.BaseCommit, result)
|
||||
}
|
||||
|
||||
if len(result.Conflicts) > 0 {
|
||||
return fmt.Errorf("%d conflicts — see above for details", len(result.Conflicts))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
49
packages/browseros/tools/bdev/cmd/common.go
Normal file
49
packages/browseros/tools/bdev/cmd/common.go
Normal file
@@ -0,0 +1,49 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/browseros-ai/BrowserOS/packages/browseros/tools/bdev/internal/repo"
|
||||
"github.com/browseros-ai/BrowserOS/packages/browseros/tools/bdev/internal/workspace"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func repoInfo() (*repo.Info, error) {
|
||||
return appState.RepoInfo()
|
||||
}
|
||||
|
||||
func resolveWorkspace(positional []string, src string) (workspace.Entry, error) {
|
||||
name := ""
|
||||
if len(positional) > 0 {
|
||||
name = positional[0]
|
||||
}
|
||||
return appState.ResolveWorkspace(name, src)
|
||||
}
|
||||
|
||||
func splitWorkspaceAndFilters(cmd *cobra.Command, args []string) ([]string, []string) {
|
||||
atDash := cmd.ArgsLenAtDash()
|
||||
if atDash == -1 {
|
||||
return args, nil
|
||||
}
|
||||
return args[:atDash], args[atDash:]
|
||||
}
|
||||
|
||||
func ensureRepoConfigured(override string) error {
|
||||
if override == "" && appState.Config.PatchesRepo != "" {
|
||||
return nil
|
||||
}
|
||||
root := override
|
||||
if root == "" {
|
||||
discovered, err := repo.Discover(appState.CWD)
|
||||
if err != nil {
|
||||
return fmt.Errorf(`unable to discover patches repo; pass --patches-repo or run from packages/browseros`)
|
||||
}
|
||||
root = discovered
|
||||
}
|
||||
info, err := repo.Load(root)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
appState.Config.PatchesRepo = info.Root
|
||||
return nil
|
||||
}
|
||||
39
packages/browseros/tools/bdev/cmd/continue.go
Normal file
39
packages/browseros/tools/bdev/cmd/continue.go
Normal file
@@ -0,0 +1,39 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/browseros-ai/BrowserOS/packages/browseros/tools/bdev/internal/engine"
|
||||
"github.com/browseros-ai/BrowserOS/packages/browseros/tools/bdev/internal/resolve"
|
||||
"github.com/browseros-ai/BrowserOS/packages/browseros/tools/bdev/internal/ui"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func init() {
|
||||
command := &cobra.Command{
|
||||
Use: "continue",
|
||||
Annotations: map[string]string{"group": "Conflict:"},
|
||||
Short: "Advance to the next conflict after fixing the current one",
|
||||
Args: cobra.NoArgs,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
ws, err := resolve.FindActive(appState.Registry, appState.CWD)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
result, err := engine.Continue(cmd.Context(), ws)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return renderResult(result, func() {
|
||||
fmt.Println(ui.Success(fmt.Sprintf("Advanced conflict resolution for %s", ws.Name)))
|
||||
if len(result.Conflicts) > 0 {
|
||||
fmt.Println(ui.Warning("Next conflict"))
|
||||
for _, conflict := range result.Conflicts {
|
||||
fmt.Printf(" %s\n", conflict.ChromiumPath)
|
||||
}
|
||||
}
|
||||
})
|
||||
},
|
||||
}
|
||||
rootCmd.AddCommand(command)
|
||||
}
|
||||
@@ -2,113 +2,52 @@ package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"bdev/internal/config"
|
||||
"bdev/internal/git"
|
||||
"bdev/internal/patch"
|
||||
"bdev/internal/ui"
|
||||
|
||||
"github.com/browseros-ai/BrowserOS/packages/browseros/tools/bdev/internal/engine"
|
||||
"github.com/browseros-ai/BrowserOS/packages/browseros/tools/bdev/internal/ui"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var diffCmd = &cobra.Command{
|
||||
Use: "diff",
|
||||
Short: "Preview what push or pull would do",
|
||||
RunE: runDiff,
|
||||
}
|
||||
|
||||
var diffDirection string
|
||||
|
||||
func init() {
|
||||
diffCmd.Flags().StringVar(&diffDirection, "direction", "push", "\"push\" or \"pull\"")
|
||||
rootCmd.AddCommand(diffCmd)
|
||||
var src string
|
||||
command := &cobra.Command{
|
||||
Use: "diff [workspace]",
|
||||
Annotations: map[string]string{"group": "Core:"},
|
||||
Short: "Preview patch differences for a workspace",
|
||||
Args: cobra.MaximumNArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
ws, err := resolveWorkspace(args, src)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
info, err := repoInfo()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
status, err := engine.InspectWorkspace(cmd.Context(), ws, info)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return renderResult(status, func() {
|
||||
fmt.Println(ui.Title(fmt.Sprintf("%s patch diff", ws.Name)))
|
||||
printGroup("Needs apply", status.NeedsApply)
|
||||
printGroup("Needs update", status.NeedsUpdate)
|
||||
printGroup("Orphaned", status.Orphaned)
|
||||
})
|
||||
},
|
||||
}
|
||||
command.Flags().StringVar(&src, "src", "", "Chromium checkout path to operate on directly")
|
||||
rootCmd.AddCommand(command)
|
||||
}
|
||||
|
||||
func runDiff(cmd *cobra.Command, args []string) error {
|
||||
ctx, err := config.LoadContext()
|
||||
if err != nil {
|
||||
return err
|
||||
func printGroup(title string, items []string) {
|
||||
if len(items) == 0 {
|
||||
fmt.Printf("%s %s\n", ui.Muted(title+":"), ui.Muted("none"))
|
||||
return
|
||||
}
|
||||
|
||||
switch diffDirection {
|
||||
case "push":
|
||||
return diffPush(ctx)
|
||||
case "pull":
|
||||
return diffPull(ctx)
|
||||
default:
|
||||
return fmt.Errorf("invalid direction %q — use \"push\" or \"pull\"", diffDirection)
|
||||
fmt.Printf("%s\n", ui.Header(title+":"))
|
||||
for _, item := range items {
|
||||
fmt.Printf(" %s\n", strings.TrimSpace(item))
|
||||
}
|
||||
}
|
||||
|
||||
func diffPush(ctx *config.Context) error {
|
||||
nameStatus, err := git.DiffNameStatus(ctx.ChromiumDir, ctx.BaseCommit)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if len(nameStatus) == 0 {
|
||||
fmt.Println(ui.MutedStyle.Render("No local changes to push."))
|
||||
return nil
|
||||
}
|
||||
|
||||
fmt.Println(ui.TitleStyle.Render("bdev diff --direction push"))
|
||||
fmt.Println()
|
||||
|
||||
for path, op := range nameStatus {
|
||||
prefix := ui.ModifiedPrefix
|
||||
switch op {
|
||||
case patch.OpAdded:
|
||||
prefix = ui.AddedPrefix
|
||||
case patch.OpDeleted:
|
||||
prefix = ui.DeletedPrefix
|
||||
}
|
||||
fmt.Printf(" %s %s\n", prefix, path)
|
||||
}
|
||||
fmt.Println()
|
||||
fmt.Println(ui.MutedStyle.Render(fmt.Sprintf("%d files would be pushed", len(nameStatus))))
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func diffPull(ctx *config.Context) error {
|
||||
repoPatchSet, err := patch.ReadPatchSet(ctx.PatchesDir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
diffOutput, err := git.DiffFull(ctx.ChromiumDir, ctx.BaseCommit)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
localPatchSet, err := patch.ParseUnifiedDiff(diffOutput)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
delta := patch.Compare(localPatchSet, repoPatchSet)
|
||||
|
||||
total := len(delta.NeedsUpdate) + len(delta.NeedsApply)
|
||||
if total == 0 && len(delta.Deleted) == 0 {
|
||||
fmt.Println(ui.MutedStyle.Render("Already up to date."))
|
||||
return nil
|
||||
}
|
||||
|
||||
fmt.Println(ui.TitleStyle.Render("bdev diff --direction pull"))
|
||||
fmt.Println()
|
||||
|
||||
for _, f := range delta.NeedsUpdate {
|
||||
fmt.Printf(" %s %s %s\n", ui.ModifiedPrefix, f, ui.MutedStyle.Render("(update)"))
|
||||
}
|
||||
for _, f := range delta.NeedsApply {
|
||||
fmt.Printf(" %s %s %s\n", ui.AddedPrefix, f, ui.MutedStyle.Render("(new)"))
|
||||
}
|
||||
for _, f := range delta.Deleted {
|
||||
fmt.Printf(" %s %s %s\n", ui.DeletedPrefix, f, ui.MutedStyle.Render("(delete)"))
|
||||
}
|
||||
|
||||
fmt.Println()
|
||||
fmt.Println(ui.MutedStyle.Render(fmt.Sprintf("%d files would be changed", total+len(delta.Deleted))))
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
73
packages/browseros/tools/bdev/cmd/extract.go
Normal file
73
packages/browseros/tools/bdev/cmd/extract.go
Normal file
@@ -0,0 +1,73 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/browseros-ai/BrowserOS/packages/browseros/tools/bdev/internal/engine"
|
||||
"github.com/browseros-ai/BrowserOS/packages/browseros/tools/bdev/internal/ui"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func init() {
|
||||
var src string
|
||||
var commit string
|
||||
var rangeMode bool
|
||||
var squash bool
|
||||
var base string
|
||||
command := &cobra.Command{
|
||||
Use: "extract [workspace] [--range <start> <end>] [-- files...]",
|
||||
Annotations: map[string]string{"group": "Core:"},
|
||||
Short: "Extract workspace changes back to chromium_patches",
|
||||
Args: cobra.ArbitraryArgs,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
positional, filters := splitWorkspaceAndFilters(cmd, args)
|
||||
workspaceArgs := positional
|
||||
rangeStart := ""
|
||||
rangeEnd := ""
|
||||
if rangeMode {
|
||||
if len(positional) < 2 || len(positional) > 3 {
|
||||
return fmt.Errorf(`range mode expects "bdev extract [workspace] --range <start> <end>"`)
|
||||
}
|
||||
rangeStart = positional[len(positional)-2]
|
||||
rangeEnd = positional[len(positional)-1]
|
||||
workspaceArgs = positional[:len(positional)-2]
|
||||
}
|
||||
if len(workspaceArgs) > 1 {
|
||||
return fmt.Errorf("expected at most one workspace name")
|
||||
}
|
||||
ws, err := resolveWorkspace(workspaceArgs, src)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
info, err := repoInfo()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
result, err := engine.Extract(cmd.Context(), engine.ExtractOptions{
|
||||
Workspace: ws,
|
||||
Repo: info,
|
||||
Commit: commit,
|
||||
RangeStart: rangeStart,
|
||||
RangeEnd: rangeEnd,
|
||||
Squash: squash,
|
||||
Base: base,
|
||||
Filters: filters,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return renderResult(result, func() {
|
||||
fmt.Println(ui.Title(fmt.Sprintf("Extracted patches from %s", ws.Name)))
|
||||
fmt.Printf("%s %s\n", ui.Muted("mode:"), result.Mode)
|
||||
fmt.Printf("%s %d\n", ui.Muted("written:"), len(result.Written))
|
||||
fmt.Printf("%s %d\n", ui.Muted("deleted:"), len(result.Deleted))
|
||||
})
|
||||
},
|
||||
}
|
||||
command.Flags().StringVar(&src, "src", "", "Chromium checkout path to operate on directly")
|
||||
command.Flags().StringVar(&commit, "commit", "", "Extract from a single commit")
|
||||
command.Flags().BoolVar(&rangeMode, "range", false, "Extract from a commit range")
|
||||
command.Flags().BoolVar(&squash, "squash", false, "Squash a range into a cumulative diff")
|
||||
command.Flags().StringVar(&base, "base", "", "Override BASE_COMMIT for extraction")
|
||||
rootCmd.AddCommand(command)
|
||||
}
|
||||
@@ -1,115 +0,0 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"bdev/internal/config"
|
||||
"bdev/internal/git"
|
||||
"bdev/internal/ui"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var initCmd = &cobra.Command{
|
||||
Use: "init",
|
||||
Short: "Initialize a Chromium checkout for bdev",
|
||||
Long: "Sets up a .bros/ directory in the current Chromium checkout,\nlinking it to a BrowserOS patches repository.",
|
||||
RunE: runInit,
|
||||
}
|
||||
|
||||
var (
|
||||
initPatchesRepo string
|
||||
initName string
|
||||
)
|
||||
|
||||
func init() {
|
||||
initCmd.Flags().StringVar(&initPatchesRepo, "patches-repo", "", "path to BrowserOS packages directory (required)")
|
||||
initCmd.Flags().StringVar(&initName, "name", "", "human name for this checkout (default: directory name)")
|
||||
_ = initCmd.MarkFlagRequired("patches-repo")
|
||||
rootCmd.AddCommand(initCmd)
|
||||
}
|
||||
|
||||
func runInit(cmd *cobra.Command, args []string) error {
|
||||
cwd, err := os.Getwd()
|
||||
if err != nil {
|
||||
return fmt.Errorf("getting cwd: %w", err)
|
||||
}
|
||||
|
||||
if !config.LooksLikeChromium(cwd) {
|
||||
return fmt.Errorf("current directory does not look like a Chromium checkout (missing chrome/, base/, or .git/)")
|
||||
}
|
||||
|
||||
brosDir := filepath.Join(cwd, config.BrosDirName)
|
||||
if _, err := os.Stat(filepath.Join(brosDir, "config.yaml")); err == nil {
|
||||
return fmt.Errorf(".bros/config.yaml already exists — checkout already initialized")
|
||||
}
|
||||
|
||||
patchesRepo, err := filepath.Abs(initPatchesRepo)
|
||||
if err != nil {
|
||||
return fmt.Errorf("resolving patches repo path: %w", err)
|
||||
}
|
||||
|
||||
patchesDir := filepath.Join(patchesRepo, "chromium_patches")
|
||||
if _, err := os.Stat(patchesDir); err != nil {
|
||||
return fmt.Errorf("chromium_patches/ not found in %s", patchesRepo)
|
||||
}
|
||||
|
||||
baseCommit, err := config.ReadBaseCommit(patchesRepo)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if !git.CommitExists(cwd, baseCommit) {
|
||||
return fmt.Errorf("BASE_COMMIT %s not found in this checkout's git history", baseCommit)
|
||||
}
|
||||
|
||||
name := initName
|
||||
if name == "" {
|
||||
name = filepath.Base(cwd)
|
||||
}
|
||||
|
||||
cfg := &config.Config{
|
||||
Name: name,
|
||||
PatchesRepo: patchesRepo,
|
||||
}
|
||||
if err := config.WriteConfig(brosDir, cfg); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Create logs directory
|
||||
if err := os.MkdirAll(filepath.Join(brosDir, "logs"), 0o755); err != nil {
|
||||
return fmt.Errorf("creating logs directory: %w", err)
|
||||
}
|
||||
|
||||
chromiumVersion, _ := config.ReadChromiumVersion(patchesRepo)
|
||||
|
||||
// Count existing patches
|
||||
patchCount := 0
|
||||
_ = filepath.Walk(patchesDir, func(path string, info os.FileInfo, err error) error {
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
if !info.IsDir() {
|
||||
patchCount++
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
fmt.Println(ui.TitleStyle.Render("bdev init"))
|
||||
fmt.Println()
|
||||
fmt.Printf(" %s %s\n", ui.LabelStyle.Render("Checkout:"), ui.ValueStyle.Render(name))
|
||||
fmt.Printf(" %s %s\n", ui.LabelStyle.Render("Directory:"), cwd)
|
||||
fmt.Printf(" %s %s\n", ui.LabelStyle.Render("Patches repo:"), patchesRepo)
|
||||
fmt.Printf(" %s %s\n", ui.LabelStyle.Render("Base commit:"), baseCommit[:min(12, len(baseCommit))])
|
||||
if chromiumVersion != "" {
|
||||
fmt.Printf(" %s %s\n", ui.LabelStyle.Render("Chromium:"), chromiumVersion)
|
||||
}
|
||||
fmt.Printf(" %s %d files\n", ui.LabelStyle.Render("Patches:"), patchCount)
|
||||
fmt.Println()
|
||||
fmt.Println(ui.SuccessStyle.Render("Initialized .bros/config.yaml"))
|
||||
fmt.Println(ui.MutedStyle.Render("Run 'bdev pull' to apply patches, or 'bdev push' to extract."))
|
||||
|
||||
return nil
|
||||
}
|
||||
49
packages/browseros/tools/bdev/cmd/list.go
Normal file
49
packages/browseros/tools/bdev/cmd/list.go
Normal file
@@ -0,0 +1,49 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/browseros-ai/BrowserOS/packages/browseros/tools/bdev/internal/engine"
|
||||
"github.com/browseros-ai/BrowserOS/packages/browseros/tools/bdev/internal/ui"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func init() {
|
||||
command := &cobra.Command{
|
||||
Use: "list",
|
||||
Aliases: []string{"ls"},
|
||||
Annotations: map[string]string{"group": "Workspace:"},
|
||||
Short: "List registered workspaces and their sync state",
|
||||
Args: cobra.NoArgs,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if len(appState.Registry.Workspaces) == 0 {
|
||||
return renderResult(map[string]any{"workspaces": []any{}}, func() {
|
||||
fmt.Println("No workspaces registered. Run `bdev add <name> <path>`.")
|
||||
})
|
||||
}
|
||||
info, err := repoInfo()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rows := make([][]string, 0, len(appState.Registry.Workspaces))
|
||||
statuses := make([]*engine.WorkspaceStatus, 0, len(appState.Registry.Workspaces))
|
||||
for _, ws := range appState.Registry.Workspaces {
|
||||
status, err := engine.InspectWorkspace(cmd.Context(), ws, info)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
statuses = append(statuses, status)
|
||||
rows = append(rows, []string{
|
||||
ws.Name,
|
||||
status.SyncState,
|
||||
fmt.Sprintf("%d/%d/%d", len(status.UpToDate), len(status.NeedsUpdate), len(status.Orphaned)),
|
||||
ws.Path,
|
||||
})
|
||||
}
|
||||
return renderResult(map[string]any{"workspaces": statuses}, func() {
|
||||
fmt.Println(ui.RenderTable([]string{"NAME", "STATE", "PATCHES", "PATH"}, rows))
|
||||
})
|
||||
},
|
||||
}
|
||||
rootCmd.AddCommand(command)
|
||||
}
|
||||
41
packages/browseros/tools/bdev/cmd/publish.go
Normal file
41
packages/browseros/tools/bdev/cmd/publish.go
Normal file
@@ -0,0 +1,41 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/browseros-ai/BrowserOS/packages/browseros/tools/bdev/internal/engine"
|
||||
"github.com/browseros-ai/BrowserOS/packages/browseros/tools/bdev/internal/ui"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func init() {
|
||||
var message string
|
||||
command := &cobra.Command{
|
||||
Use: "publish [remote]",
|
||||
Annotations: map[string]string{"group": "Remote:"},
|
||||
Short: "Commit and push chromium_patches to a remote",
|
||||
Args: cobra.MaximumNArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
info, err := repoInfo()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
remote := "origin"
|
||||
if len(args) == 1 {
|
||||
remote = args[0]
|
||||
}
|
||||
result, err := engine.Publish(cmd.Context(), info, remote, message)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return renderResult(result, func() {
|
||||
fmt.Println(ui.Success("Published chromium_patches"))
|
||||
fmt.Printf("%s %s\n", ui.Muted("remote:"), result.Remote)
|
||||
fmt.Printf("%s %s\n", ui.Muted("branch:"), result.Branch)
|
||||
fmt.Printf("%s %s\n", ui.Muted("message:"), result.Message)
|
||||
})
|
||||
},
|
||||
}
|
||||
command.Flags().StringVarP(&message, "message", "m", "", "Commit message for the patch publish commit")
|
||||
rootCmd.AddCommand(command)
|
||||
}
|
||||
@@ -1,142 +0,0 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"bdev/internal/config"
|
||||
"bdev/internal/engine"
|
||||
"bdev/internal/git"
|
||||
"bdev/internal/log"
|
||||
"bdev/internal/ui"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var pullCmd = &cobra.Command{
|
||||
Use: "pull [remote] [-- file1 file2 ...]",
|
||||
Short: "Pull patches from repo to checkout",
|
||||
Long: `Apply patches from the patches repository to the current Chromium
|
||||
checkout. Use an optional remote (for example: 'bdev pull origin')
|
||||
to fetch/rebase the patches repo before applying changes locally.`,
|
||||
RunE: runPull,
|
||||
}
|
||||
|
||||
var (
|
||||
pullDryRun bool
|
||||
pullRemote string
|
||||
pullNoSync bool
|
||||
pullRebase bool
|
||||
pullKeepLocalOnly bool
|
||||
)
|
||||
|
||||
func init() {
|
||||
pullCmd.Flags().BoolVar(&pullDryRun, "dry-run", false, "show what would change")
|
||||
pullCmd.Flags().StringVar(&pullRemote, "remote", "", "patches repo remote to sync before pull")
|
||||
pullCmd.Flags().BoolVar(&pullNoSync, "no-sync", false, "skip syncing patches repo from remote")
|
||||
pullCmd.Flags().BoolVar(&pullRebase, "rebase", true, "use git pull --rebase when syncing remote")
|
||||
pullCmd.Flags().BoolVar(&pullKeepLocalOnly, "keep-local-only", true, "keep local-only checkout changes that are not in patches repo")
|
||||
rootCmd.AddCommand(pullCmd)
|
||||
}
|
||||
|
||||
func runPull(cmd *cobra.Command, args []string) error {
|
||||
ctx, err := config.LoadContext()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
activity := ui.NewActivity(verbose)
|
||||
remote, files, err := resolveRemoteAndFiles(ctx.PatchesRepo, args, pullRemote)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
shouldSync := remote != "" && !pullNoSync && !pullDryRun
|
||||
if shouldSync {
|
||||
dirty, err := git.IsDirty(ctx.PatchesRepo)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if dirty {
|
||||
return fmt.Errorf("patches repo has local changes; commit/stash before syncing remote %q", remote)
|
||||
}
|
||||
|
||||
activity.Step("syncing patches repo from remote %q", remote)
|
||||
beforeRev, _ := git.HeadRev(ctx.PatchesRepo)
|
||||
|
||||
if err := git.Fetch(ctx.PatchesRepo, remote); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
branch, detached, err := git.CurrentBranch(ctx.PatchesRepo)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if detached {
|
||||
activity.Warn("patches repo is in detached HEAD; fetched remote but skipped pull/rebase")
|
||||
} else {
|
||||
if err := git.Pull(ctx.PatchesRepo, remote, branch, pullRebase); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
afterRev, _ := git.HeadRev(ctx.PatchesRepo)
|
||||
if beforeRev != "" && afterRev != "" && beforeRev != afterRev {
|
||||
activity.Success("patches repo advanced %s -> %s", shortRev(beforeRev), shortRev(afterRev))
|
||||
} else {
|
||||
activity.Info("patches repo already up to date")
|
||||
}
|
||||
|
||||
ctx, err = config.LoadContext()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
} else if remote != "" && pullDryRun {
|
||||
activity.Info("dry run enabled — skipping remote sync")
|
||||
} else if remote != "" && pullNoSync {
|
||||
activity.Info("remote %q provided, but sync is disabled via --no-sync", remote)
|
||||
}
|
||||
|
||||
opts := engine.PullOpts{
|
||||
DryRun: pullDryRun,
|
||||
Files: files,
|
||||
KeepLocalOnly: pullKeepLocalOnly,
|
||||
}
|
||||
|
||||
if pullDryRun {
|
||||
activity.Info("dry run enabled — no files will be modified")
|
||||
activity.Divider()
|
||||
}
|
||||
|
||||
activity.Step("computing patch delta and applying updates")
|
||||
result, err := engine.Pull(ctx, opts)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fmt.Print(ui.RenderPullResult(result))
|
||||
|
||||
if len(result.Conflicts) > 0 {
|
||||
fmt.Print(ui.RenderConflictReport(result.Conflicts))
|
||||
}
|
||||
|
||||
if !pullDryRun {
|
||||
repoRev, _ := git.HeadRev(ctx.PatchesRepo)
|
||||
ctx.State.LastPull = &config.SyncEvent{
|
||||
PatchesRepoRev: repoRev,
|
||||
BaseCommit: ctx.BaseCommit,
|
||||
Timestamp: time.Now(),
|
||||
FileCount: len(result.Applied) + len(result.Deleted) + len(result.Reverted) + len(result.LocalOnly) + len(result.Skipped),
|
||||
}
|
||||
_ = config.WriteState(ctx.BrosDir, ctx.State)
|
||||
|
||||
logger := log.New(ctx.BrosDir)
|
||||
_ = logger.LogPull(ctx.BaseCommit, repoRev, result)
|
||||
}
|
||||
|
||||
if len(result.Conflicts) > 0 {
|
||||
return fmt.Errorf("%d conflicts — see above for details", len(result.Conflicts))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -1,238 +0,0 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"bdev/internal/config"
|
||||
"bdev/internal/engine"
|
||||
"bdev/internal/git"
|
||||
"bdev/internal/log"
|
||||
"bdev/internal/patch"
|
||||
"bdev/internal/ui"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var pushCmd = &cobra.Command{
|
||||
Use: "push [remote] [-- file1 file2 ...]",
|
||||
Short: "Push local changes to patches repo",
|
||||
Long: `Extract diffs from the current Chromium checkout and write them
|
||||
to the patches repository. When a remote is provided (for example:
|
||||
'bdev push origin'), bdev commits patch changes and pushes upstream.`,
|
||||
RunE: runPush,
|
||||
}
|
||||
|
||||
var (
|
||||
pushDryRun bool
|
||||
pushRemote string
|
||||
pushNoSync bool
|
||||
pushRebase bool
|
||||
pushMessage string
|
||||
)
|
||||
|
||||
func init() {
|
||||
pushCmd.Flags().BoolVar(&pushDryRun, "dry-run", false, "show what would be pushed")
|
||||
pushCmd.Flags().StringVar(&pushRemote, "remote", "", "patches repo remote to publish to")
|
||||
pushCmd.Flags().BoolVar(&pushNoSync, "no-sync", false, "skip syncing patches repo from remote before publish")
|
||||
pushCmd.Flags().BoolVar(&pushRebase, "rebase", true, "use git pull --rebase when syncing before publish")
|
||||
pushCmd.Flags().StringVarP(&pushMessage, "message", "m", "", "commit message when publishing to remote")
|
||||
rootCmd.AddCommand(pushCmd)
|
||||
}
|
||||
|
||||
func runPush(cmd *cobra.Command, args []string) error {
|
||||
ctx, err := config.LoadContext()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
activity := ui.NewActivity(verbose)
|
||||
remote, files, err := resolveRemoteAndFiles(ctx.PatchesRepo, args, pushRemote)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
shouldPublish := remote != "" && !pushDryRun
|
||||
if shouldPublish {
|
||||
dirty, err := git.IsDirty(ctx.PatchesRepo)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if dirty {
|
||||
return fmt.Errorf("patches repo has local changes; commit/stash before publishing to remote %q", remote)
|
||||
}
|
||||
}
|
||||
|
||||
if shouldPublish && !pushNoSync {
|
||||
if err := syncPatchesRepo(activity, ctx.PatchesRepo, remote, pushRebase); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if remote != "" && pushDryRun {
|
||||
activity.Info("dry run enabled — skipping remote sync and publish")
|
||||
}
|
||||
|
||||
opts := engine.PushOpts{
|
||||
DryRun: pushDryRun,
|
||||
Files: files,
|
||||
}
|
||||
|
||||
if pushDryRun {
|
||||
activity.Info("dry run enabled — no patch files will be written")
|
||||
activity.Divider()
|
||||
}
|
||||
|
||||
activity.Step("extracting checkout changes into patches")
|
||||
result, err := engine.Push(ctx, opts)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
renderPushResult(result, pushDryRun)
|
||||
|
||||
if !pushDryRun {
|
||||
if remote != "" {
|
||||
if err := publishPatchChanges(activity, ctx, remote, result, pushMessage); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// Update state
|
||||
repoRev, _ := git.HeadRev(ctx.PatchesRepo)
|
||||
ctx.State.LastPush = &config.SyncEvent{
|
||||
PatchesRepoRev: repoRev,
|
||||
Timestamp: time.Now(),
|
||||
FileCount: result.Total() + len(result.Stale),
|
||||
}
|
||||
_ = config.WriteState(ctx.BrosDir, ctx.State)
|
||||
|
||||
// Activity log
|
||||
logger := log.New(ctx.BrosDir)
|
||||
_ = logger.LogPush(ctx.BaseCommit, result)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func syncPatchesRepo(activity *ui.Activity, patchesRepo, remote string, rebase bool) error {
|
||||
activity.Step("syncing patches repo from remote %q", remote)
|
||||
beforeRev, _ := git.HeadRev(patchesRepo)
|
||||
|
||||
if err := git.Fetch(patchesRepo, remote); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
branch, detached, err := git.CurrentBranch(patchesRepo)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if detached {
|
||||
return fmt.Errorf("patches repo is in detached HEAD; cannot sync for publish")
|
||||
}
|
||||
|
||||
if err := git.Pull(patchesRepo, remote, branch, rebase); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
afterRev, _ := git.HeadRev(patchesRepo)
|
||||
if beforeRev != "" && afterRev != "" && beforeRev != afterRev {
|
||||
activity.Success("patches repo advanced %s -> %s", shortRev(beforeRev), shortRev(afterRev))
|
||||
} else {
|
||||
activity.Info("patches repo already up to date")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func publishPatchChanges(
|
||||
activity *ui.Activity,
|
||||
ctx *config.Context,
|
||||
remote string,
|
||||
result *patch.PushResult,
|
||||
commitMessage string,
|
||||
) error {
|
||||
dirty, err := git.IsDirty(ctx.PatchesRepo, "chromium_patches")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !dirty {
|
||||
activity.Info("no patch repository changes to commit")
|
||||
return nil
|
||||
}
|
||||
|
||||
branch, detached, err := git.CurrentBranch(ctx.PatchesRepo)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if detached {
|
||||
return fmt.Errorf("patches repo is in detached HEAD; cannot publish")
|
||||
}
|
||||
|
||||
message := commitMessage
|
||||
if message == "" {
|
||||
message = fmt.Sprintf(
|
||||
"bdev push: %s (%d modified, %d added, %d deleted, %d stale)",
|
||||
ctx.Config.Name,
|
||||
len(result.Modified),
|
||||
len(result.Added),
|
||||
len(result.Deleted),
|
||||
len(result.Stale),
|
||||
)
|
||||
}
|
||||
|
||||
activity.Step("committing patch changes to %s", branch)
|
||||
if err := git.Add(ctx.PatchesRepo, "chromium_patches"); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := git.Commit(ctx.PatchesRepo, message); err != nil {
|
||||
return err
|
||||
}
|
||||
activity.Success("created patch commit")
|
||||
|
||||
activity.Step("pushing patch commit to %s/%s", remote, branch)
|
||||
if err := git.Push(ctx.PatchesRepo, remote, branch); err != nil {
|
||||
return err
|
||||
}
|
||||
activity.Success("remote publish complete")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func renderPushResult(r *patch.PushResult, dryRun bool) {
|
||||
if r.Total() == 0 && len(r.Stale) == 0 {
|
||||
fmt.Println(ui.MutedStyle.Render("Nothing to push — checkout matches patches repo."))
|
||||
return
|
||||
}
|
||||
|
||||
verb := "Pushed"
|
||||
if dryRun {
|
||||
verb = "Would push"
|
||||
}
|
||||
|
||||
fmt.Println(ui.TitleStyle.Render("bdev push"))
|
||||
fmt.Println()
|
||||
|
||||
for _, f := range r.Added {
|
||||
fmt.Printf(" %s %s\n", ui.AddedPrefix, f)
|
||||
}
|
||||
for _, f := range r.Modified {
|
||||
fmt.Printf(" %s %s\n", ui.ModifiedPrefix, f)
|
||||
}
|
||||
for _, f := range r.Deleted {
|
||||
fmt.Printf(" %s %s\n", ui.DeletedPrefix, f)
|
||||
}
|
||||
for _, f := range r.Stale {
|
||||
fmt.Printf(" %s %s\n", ui.SkippedPrefix, ui.MutedStyle.Render(f+" (stale, removed)"))
|
||||
}
|
||||
|
||||
fmt.Println()
|
||||
summary := fmt.Sprintf("%s %d patches", verb, r.Total())
|
||||
detail := fmt.Sprintf(" (%d modified, %d added, %d deleted)",
|
||||
len(r.Modified), len(r.Added), len(r.Deleted))
|
||||
fmt.Print(ui.SuccessStyle.Render(summary))
|
||||
fmt.Println(ui.MutedStyle.Render(detail))
|
||||
|
||||
if len(r.Stale) > 0 {
|
||||
fmt.Println(ui.MutedStyle.Render(fmt.Sprintf("Cleaned %d stale patches", len(r.Stale))))
|
||||
}
|
||||
}
|
||||
33
packages/browseros/tools/bdev/cmd/remove.go
Normal file
33
packages/browseros/tools/bdev/cmd/remove.go
Normal file
@@ -0,0 +1,33 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/browseros-ai/BrowserOS/packages/browseros/tools/bdev/internal/ui"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func init() {
|
||||
command := &cobra.Command{
|
||||
Use: "remove <name>",
|
||||
Aliases: []string{"rm"},
|
||||
Annotations: map[string]string{"group": "Workspace:"},
|
||||
Short: "Unregister a workspace",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
entry, err := appState.Registry.Remove(args[0])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := appState.Save(); err != nil {
|
||||
return err
|
||||
}
|
||||
return renderResult(map[string]any{"workspace": entry}, func() {
|
||||
fmt.Println(ui.Success("Removed workspace"))
|
||||
fmt.Printf("%s %s\n", ui.Muted("name:"), entry.Name)
|
||||
fmt.Printf("%s %s\n", ui.Muted("path:"), entry.Path)
|
||||
})
|
||||
},
|
||||
}
|
||||
rootCmd.AddCommand(command)
|
||||
}
|
||||
@@ -1,31 +1,129 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/browseros-ai/BrowserOS/packages/browseros/tools/bdev/internal/app"
|
||||
"github.com/browseros-ai/BrowserOS/packages/browseros/tools/bdev/internal/ui"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var Version = "dev"
|
||||
|
||||
var (
|
||||
verbose bool
|
||||
version string
|
||||
jsonOut bool
|
||||
verbose bool
|
||||
appState *app.App
|
||||
)
|
||||
|
||||
var groupOrder = []string{
|
||||
"Workspace:",
|
||||
"Core:",
|
||||
"Conflict:",
|
||||
"Remote:",
|
||||
}
|
||||
|
||||
func helpHeader(s string) string { return ui.Header(s) }
|
||||
func helpCmdCol(s string) string { return ui.Command(s) }
|
||||
func helpHint(s string) string { return ui.Hint(s) }
|
||||
func helpAliases(aliases []string) string {
|
||||
return ui.Aliases(aliases)
|
||||
}
|
||||
|
||||
func groupedHelp(cmd *cobra.Command) string {
|
||||
groups := map[string][]*cobra.Command{}
|
||||
for _, child := range cmd.Commands() {
|
||||
if !child.IsAvailableCommand() && child.Name() != "help" {
|
||||
continue
|
||||
}
|
||||
group := child.Annotations["group"]
|
||||
if group == "" {
|
||||
group = "Core:"
|
||||
}
|
||||
groups[group] = append(groups[group], child)
|
||||
}
|
||||
var builder strings.Builder
|
||||
for _, group := range groupOrder {
|
||||
commands, ok := groups[group]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
builder.WriteString("\n" + helpHeader(group) + "\n")
|
||||
for _, child := range commands {
|
||||
line := " " + helpCmdCol(fmt.Sprintf("%-14s", child.Name())) + " " + child.Short
|
||||
if len(child.Aliases) > 0 {
|
||||
line += " " + helpAliases(child.Aliases)
|
||||
}
|
||||
builder.WriteString(line + "\n")
|
||||
}
|
||||
}
|
||||
return builder.String()
|
||||
}
|
||||
|
||||
const usageTemplate = `{{helpHeader "Usage:"}}{{if .Runnable}}
|
||||
{{.UseLine}}{{end}}{{if .HasAvailableSubCommands}}
|
||||
{{.CommandPath}} [command]{{end}}{{if gt (len .Aliases) 0}}
|
||||
|
||||
{{helpHeader "Aliases:"}}
|
||||
{{.NameAndAliases}}{{end}}{{if .HasExample}}
|
||||
|
||||
{{helpHeader "Examples:"}}
|
||||
{{.Example}}{{end}}{{if .HasAvailableSubCommands}}
|
||||
{{groupedHelp .}}{{end}}{{if .HasAvailableLocalFlags}}
|
||||
|
||||
{{helpHeader "Flags:"}}
|
||||
{{.LocalFlags.FlagUsages | trimTrailingWhitespaces}}{{end}}{{if .HasAvailableInheritedFlags}}
|
||||
|
||||
{{helpHeader "Global Flags:"}}
|
||||
{{.InheritedFlags.FlagUsages | trimTrailingWhitespaces}}{{end}}{{if .HasAvailableSubCommands}}
|
||||
|
||||
{{helpHint (printf "Use \"%s [command] --help\" for more information." .CommandPath)}}{{end}}
|
||||
`
|
||||
|
||||
var rootCmd = &cobra.Command{
|
||||
Use: "bdev",
|
||||
Short: "BrowserOS CLI — patch management, builds, and releases",
|
||||
Long: "bdev manages BrowserOS patches across Chromium checkouts.\nUse push/pull to sync patches, clone for fresh applies.",
|
||||
Short: "Workspace-centric BrowserOS patch tooling for Chromium checkouts",
|
||||
Version: Version,
|
||||
SilenceUsage: true,
|
||||
SilenceErrors: true,
|
||||
PersistentPreRunE: func(cmd *cobra.Command, args []string) error {
|
||||
var err error
|
||||
appState, err = app.Load(jsonOut, verbose, "")
|
||||
return err
|
||||
},
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
return cmd.Help()
|
||||
},
|
||||
}
|
||||
|
||||
func init() {
|
||||
rootCmd.PersistentFlags().BoolVarP(&verbose, "verbose", "v", false, "increase output detail")
|
||||
cobra.AddTemplateFunc("helpHeader", helpHeader)
|
||||
cobra.AddTemplateFunc("helpCmdCol", helpCmdCol)
|
||||
cobra.AddTemplateFunc("helpAliases", helpAliases)
|
||||
cobra.AddTemplateFunc("helpHint", helpHint)
|
||||
cobra.AddTemplateFunc("groupedHelp", groupedHelp)
|
||||
rootCmd.SetUsageTemplate(usageTemplate)
|
||||
rootCmd.PersistentFlags().BoolVar(&jsonOut, "json", false, "Emit JSON output")
|
||||
rootCmd.PersistentFlags().BoolVarP(&verbose, "verbose", "v", false, "Enable verbose output")
|
||||
rootCmd.CompletionOptions.DisableDefaultCmd = true
|
||||
}
|
||||
|
||||
func SetVersion(v string) {
|
||||
version = v
|
||||
rootCmd.Version = v
|
||||
func Execute() {
|
||||
if err := rootCmd.Execute(); err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func Execute() error {
|
||||
return rootCmd.Execute()
|
||||
func renderResult(data any, human func()) error {
|
||||
if jsonOut {
|
||||
encoder := json.NewEncoder(os.Stdout)
|
||||
encoder.SetIndent("", " ")
|
||||
return encoder.Encode(data)
|
||||
}
|
||||
human()
|
||||
return nil
|
||||
}
|
||||
|
||||
33
packages/browseros/tools/bdev/cmd/skip.go
Normal file
33
packages/browseros/tools/bdev/cmd/skip.go
Normal file
@@ -0,0 +1,33 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/browseros-ai/BrowserOS/packages/browseros/tools/bdev/internal/engine"
|
||||
"github.com/browseros-ai/BrowserOS/packages/browseros/tools/bdev/internal/resolve"
|
||||
"github.com/browseros-ai/BrowserOS/packages/browseros/tools/bdev/internal/ui"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func init() {
|
||||
command := &cobra.Command{
|
||||
Use: "skip",
|
||||
Annotations: map[string]string{"group": "Conflict:"},
|
||||
Short: "Skip the current conflict and move to the next one",
|
||||
Args: cobra.NoArgs,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
ws, err := resolve.FindActive(appState.Registry, appState.CWD)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
result, err := engine.Skip(cmd.Context(), ws)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return renderResult(result, func() {
|
||||
fmt.Println(ui.Warning(fmt.Sprintf("Skipped current conflict in %s", ws.Name)))
|
||||
})
|
||||
},
|
||||
}
|
||||
rootCmd.AddCommand(command)
|
||||
}
|
||||
@@ -1,96 +1,45 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"bdev/internal/config"
|
||||
"bdev/internal/engine"
|
||||
"bdev/internal/ui"
|
||||
|
||||
"github.com/browseros-ai/BrowserOS/packages/browseros/tools/bdev/internal/engine"
|
||||
"github.com/browseros-ai/BrowserOS/packages/browseros/tools/bdev/internal/ui"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var statusCmd = &cobra.Command{
|
||||
Use: "status",
|
||||
Short: "Show sync state between checkout and patches repo",
|
||||
RunE: runStatus,
|
||||
}
|
||||
|
||||
var (
|
||||
statusJSON bool
|
||||
statusFiles bool
|
||||
)
|
||||
|
||||
func init() {
|
||||
statusCmd.Flags().BoolVar(&statusJSON, "json", false, "output as JSON")
|
||||
statusCmd.Flags().BoolVar(&statusFiles, "files", false, "list individual files per category")
|
||||
rootCmd.AddCommand(statusCmd)
|
||||
}
|
||||
|
||||
func runStatus(cmd *cobra.Command, args []string) error {
|
||||
ctx, err := config.LoadContext()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
result, err := engine.Status(ctx, statusFiles)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if statusJSON {
|
||||
data, err := json.MarshalIndent(result, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Println(string(data))
|
||||
return nil
|
||||
}
|
||||
|
||||
renderStatus(result)
|
||||
return nil
|
||||
}
|
||||
|
||||
func renderStatus(r *engine.StatusResult) {
|
||||
fmt.Println(ui.TitleStyle.Render("bdev status"))
|
||||
fmt.Println()
|
||||
|
||||
fmt.Printf(" %s %s\n", ui.LabelStyle.Render("Checkout:"), ui.ValueStyle.Render(r.CheckoutName))
|
||||
fmt.Printf(" %s %s\n", ui.LabelStyle.Render("Base commit:"), r.BaseCommit[:min(12, len(r.BaseCommit))])
|
||||
if r.ChromiumVersion != "" {
|
||||
fmt.Printf(" %s %s\n", ui.LabelStyle.Render("Chromium:"), r.ChromiumVersion)
|
||||
}
|
||||
fmt.Printf(" %s %s\n", ui.LabelStyle.Render("Patches repo:"), r.PatchesRepo)
|
||||
|
||||
fmt.Println()
|
||||
fmt.Println(" Sync status:")
|
||||
|
||||
if r.Ahead > 0 {
|
||||
fmt.Printf(" %s %s\n",
|
||||
ui.WarningStyle.Render(fmt.Sprintf("ahead: %3d files", r.Ahead)),
|
||||
ui.MutedStyle.Render("(local changes not in patches repo)"))
|
||||
}
|
||||
if r.Behind > 0 {
|
||||
fmt.Printf(" %s %s\n",
|
||||
ui.WarningStyle.Render(fmt.Sprintf("behind: %3d files", r.Behind)),
|
||||
ui.MutedStyle.Render("(patches in repo not applied locally)"))
|
||||
}
|
||||
fmt.Printf(" %s\n",
|
||||
ui.SuccessStyle.Render(fmt.Sprintf("synced: %3d files", r.Synced)))
|
||||
|
||||
if len(r.AheadFiles) > 0 {
|
||||
fmt.Println()
|
||||
fmt.Println(" Ahead files:")
|
||||
for _, f := range r.AheadFiles {
|
||||
fmt.Printf(" %s %s\n", ui.AddedPrefix, f)
|
||||
}
|
||||
}
|
||||
if len(r.BehindFiles) > 0 {
|
||||
fmt.Println()
|
||||
fmt.Println(" Behind files:")
|
||||
for _, f := range r.BehindFiles {
|
||||
fmt.Printf(" %s %s\n", ui.WarningStyle.Render(">"), f)
|
||||
}
|
||||
}
|
||||
var src string
|
||||
command := &cobra.Command{
|
||||
Use: "status [workspace]",
|
||||
Annotations: map[string]string{"group": "Core:"},
|
||||
Short: "Show workspace sync state",
|
||||
Args: cobra.MaximumNArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
ws, err := resolveWorkspace(args, src)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
info, err := repoInfo()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
status, err := engine.InspectWorkspace(cmd.Context(), ws, info)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return renderResult(status, func() {
|
||||
fmt.Println(ui.Title(fmt.Sprintf("%s (%s)", ws.Name, status.SyncState)))
|
||||
fmt.Printf("%s %s\n", ui.Muted("path:"), ws.Path)
|
||||
fmt.Printf("%s %s\n", ui.Muted("repo head:"), status.RepoHead)
|
||||
fmt.Printf("%s %s\n", ui.Muted("last sync:"), status.LastSyncRev)
|
||||
fmt.Printf("%s %s\n", ui.Muted("last apply:"), status.LastApplyRev)
|
||||
fmt.Printf("%s %d\n", ui.Muted("needs apply:"), len(status.NeedsApply))
|
||||
fmt.Printf("%s %d\n", ui.Muted("needs update:"), len(status.NeedsUpdate))
|
||||
fmt.Printf("%s %d\n", ui.Muted("orphaned:"), len(status.Orphaned))
|
||||
})
|
||||
},
|
||||
}
|
||||
command.Flags().StringVar(&src, "src", "", "Chromium checkout path to operate on directly")
|
||||
rootCmd.AddCommand(command)
|
||||
}
|
||||
|
||||
58
packages/browseros/tools/bdev/cmd/sync.go
Normal file
58
packages/browseros/tools/bdev/cmd/sync.go
Normal file
@@ -0,0 +1,58 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/browseros-ai/BrowserOS/packages/browseros/tools/bdev/internal/engine"
|
||||
"github.com/browseros-ai/BrowserOS/packages/browseros/tools/bdev/internal/ui"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func init() {
|
||||
var src string
|
||||
var rebase bool
|
||||
var remote string
|
||||
command := &cobra.Command{
|
||||
Use: "sync [workspace]",
|
||||
Annotations: map[string]string{"group": "Core:"},
|
||||
Short: "Sync a workspace with the latest patch repo state",
|
||||
Args: cobra.MaximumNArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
ws, err := resolveWorkspace(args, src)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
info, err := repoInfo()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
result, err := engine.Sync(cmd.Context(), engine.SyncOptions{
|
||||
Workspace: ws,
|
||||
Repo: info,
|
||||
Remote: remote,
|
||||
Rebase: rebase,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return renderResult(result, func() {
|
||||
fmt.Println(ui.Title(fmt.Sprintf("Synced %s", ws.Name)))
|
||||
fmt.Printf("%s %s\n", ui.Muted("repo head:"), result.RepoHead)
|
||||
fmt.Printf("%s %d\n", ui.Muted("applied:"), len(result.Applied))
|
||||
if result.StashRef != "" {
|
||||
fmt.Printf("%s %s\n", ui.Muted("stash:"), result.StashRef)
|
||||
}
|
||||
if len(result.Conflicts) > 0 {
|
||||
fmt.Println(ui.Warning("Conflicts detected"))
|
||||
for _, conflict := range result.Conflicts {
|
||||
fmt.Printf(" %s\n", conflict)
|
||||
}
|
||||
}
|
||||
})
|
||||
},
|
||||
}
|
||||
command.Flags().StringVar(&src, "src", "", "Chromium checkout path to operate on directly")
|
||||
command.Flags().BoolVar(&rebase, "rebase", false, "Re-apply stashed local changes after syncing")
|
||||
command.Flags().StringVar(&remote, "remote", "origin", "Remote to pull from")
|
||||
rootCmd.AddCommand(command)
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"bdev/internal/git"
|
||||
)
|
||||
|
||||
func resolveRemoteAndFiles(repoDir string, args []string, explicitRemote string) (string, []string, error) {
|
||||
remote := strings.TrimSpace(explicitRemote)
|
||||
if remote != "" {
|
||||
hasRemote, err := git.HasRemote(repoDir, remote)
|
||||
if err != nil {
|
||||
return "", nil, fmt.Errorf("resolving remote %q: %w", remote, err)
|
||||
}
|
||||
if !hasRemote {
|
||||
return "", nil, fmt.Errorf("remote %q not found in patches repo", remote)
|
||||
}
|
||||
return remote, args, nil
|
||||
}
|
||||
|
||||
if len(args) == 0 {
|
||||
return "", nil, nil
|
||||
}
|
||||
|
||||
hasRemote, err := git.HasRemote(repoDir, args[0])
|
||||
if err != nil {
|
||||
return "", nil, fmt.Errorf("resolving remote %q: %w", args[0], err)
|
||||
}
|
||||
if hasRemote {
|
||||
return args[0], args[1:], nil
|
||||
}
|
||||
|
||||
return "", args, nil
|
||||
}
|
||||
|
||||
func shortRev(rev string) string {
|
||||
rev = strings.TrimSpace(rev)
|
||||
if len(rev) <= 12 {
|
||||
return rev
|
||||
}
|
||||
return rev[:12]
|
||||
}
|
||||
@@ -1,59 +0,0 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestResolveRemoteAndFiles(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
repo := initRemoteRepo(t)
|
||||
|
||||
remote, files, err := resolveRemoteAndFiles(repo, []string{"origin", "content/foo.cc"}, "")
|
||||
if err != nil {
|
||||
t.Fatalf("resolveRemoteAndFiles: %v", err)
|
||||
}
|
||||
if remote != "origin" {
|
||||
t.Fatalf("expected origin, got %q", remote)
|
||||
}
|
||||
if !reflect.DeepEqual(files, []string{"content/foo.cc"}) {
|
||||
t.Fatalf("unexpected files: %#v", files)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveRemoteAndFilesUnknownExplicitRemote(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
repo := initRemoteRepo(t)
|
||||
if _, _, err := resolveRemoteAndFiles(repo, nil, "missing"); err == nil {
|
||||
t.Fatalf("expected error for unknown explicit remote")
|
||||
}
|
||||
}
|
||||
|
||||
func initRemoteRepo(t *testing.T) string {
|
||||
t.Helper()
|
||||
|
||||
dir := filepath.Join(t.TempDir(), "patches")
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
t.Fatalf("mkdir: %v", err)
|
||||
}
|
||||
|
||||
runGitCmd(t, dir, "init")
|
||||
runGitCmd(t, dir, "remote", "add", "origin", "https://example.com/org/repo.git")
|
||||
return dir
|
||||
}
|
||||
|
||||
func runGitCmd(t *testing.T, dir string, args ...string) string {
|
||||
t.Helper()
|
||||
cmd := exec.Command("git", args...)
|
||||
cmd.Dir = dir
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
t.Fatalf("git %v failed: %v\n%s", args, err, string(out))
|
||||
}
|
||||
return string(out)
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
package e2e
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"testing"
|
||||
)
|
||||
|
||||
var bdevBinary string
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
tmpDir, err := os.MkdirTemp("", "bdev-e2e-bin-*")
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "failed to create temp dir: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
_, file, _, ok := runtime.Caller(0)
|
||||
if !ok {
|
||||
fmt.Fprintln(os.Stderr, "failed to resolve e2e test path")
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
moduleDir := filepath.Clean(filepath.Join(filepath.Dir(file), ".."))
|
||||
bdevBinary = filepath.Join(tmpDir, "bdev-e2e")
|
||||
|
||||
build := exec.Command("go", "build", "-o", bdevBinary, ".")
|
||||
build.Dir = moduleDir
|
||||
if out, err := build.CombinedOutput(); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "failed to build bdev binary: %v\n%s\n", err, string(out))
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
code := m.Run()
|
||||
_ = os.RemoveAll(tmpDir)
|
||||
os.Exit(code)
|
||||
}
|
||||
@@ -1,244 +0,0 @@
|
||||
package e2e
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
type scenario struct {
|
||||
root string
|
||||
baseCommit string
|
||||
|
||||
patchesRemote string
|
||||
patchesRepo string
|
||||
chromiumA string
|
||||
chromiumB string
|
||||
|
||||
trackedPath string
|
||||
newPath string
|
||||
}
|
||||
|
||||
type statusJSON struct {
|
||||
Ahead int
|
||||
Behind int
|
||||
Synced int
|
||||
}
|
||||
|
||||
func TestBdevOperationsE2E(t *testing.T) {
|
||||
env := setupScenario(t)
|
||||
|
||||
runBdev(t, env.chromiumA, "init", "--patches-repo", env.patchesRepo, "--name", "checkout-a")
|
||||
|
||||
statusBefore := readStatus(t, env.chromiumA)
|
||||
if statusBefore.Behind == 0 {
|
||||
t.Fatalf("expected checkout-a to be behind before pull, got %#v", statusBefore)
|
||||
}
|
||||
|
||||
pullPreview := runBdev(t, env.chromiumA, "diff", "--direction", "pull")
|
||||
assertContains(t, pullPreview, env.trackedPath)
|
||||
|
||||
runBdev(t, env.chromiumA, "pull", "--no-sync")
|
||||
assertFileContains(t, filepath.Join(env.chromiumA, env.trackedPath), "patch-v1")
|
||||
|
||||
statusAfterPull := readStatus(t, env.chromiumA)
|
||||
if statusAfterPull.Behind != 0 || statusAfterPull.Synced == 0 {
|
||||
t.Fatalf("unexpected status after pull: %#v", statusAfterPull)
|
||||
}
|
||||
|
||||
writeFile(t, filepath.Join(env.chromiumA, "base", ".keep"), "my-local-work\n")
|
||||
pullAgain := runBdev(t, env.chromiumA, "pull", "--no-sync", "base/.keep")
|
||||
assertContains(t, pullAgain, "local-only, kept")
|
||||
assertFileContains(t, filepath.Join(env.chromiumA, "base", ".keep"), "my-local-work")
|
||||
runGit(t, env.chromiumA, "checkout", env.baseCommit, "--", "base/.keep")
|
||||
|
||||
writeFile(t, filepath.Join(env.chromiumA, env.trackedPath), "patch-v2\n")
|
||||
writeFile(t, filepath.Join(env.chromiumA, env.newPath), "brand-new\n")
|
||||
pushPreview := runBdev(t, env.chromiumA, "diff", "--direction", "push")
|
||||
assertContains(t, pushPreview, env.trackedPath)
|
||||
|
||||
runBdev(t, env.chromiumA, "push", "--no-sync", env.trackedPath, env.newPath)
|
||||
assertFileContains(t, filepath.Join(env.patchesRepo, "chromium_patches", env.newPath), "diff --git")
|
||||
|
||||
// Keep the patches repo clean before remote-aware publish flow.
|
||||
commitRepo(t, env.patchesRepo, "chore: e2e checkpoint after push --no-sync")
|
||||
|
||||
writeFile(t, filepath.Join(env.chromiumA, env.trackedPath), "patch-v3\n")
|
||||
publish := runBdev(t, env.chromiumA, "push", "origin", "-m", "e2e: publish patch-v3", env.trackedPath)
|
||||
assertContains(t, publish, "remote publish complete")
|
||||
|
||||
mirror := filepath.Join(env.root, "mirror")
|
||||
runGit(t, env.root, "clone", env.patchesRemote, mirror)
|
||||
assertFileContains(t, filepath.Join(mirror, "chromium_patches", env.trackedPath), "patch-v3")
|
||||
|
||||
collab := filepath.Join(env.root, "collab")
|
||||
runGit(t, env.root, "clone", env.patchesRemote, collab)
|
||||
configRepo(t, collab)
|
||||
diffV4 := buildDiffFromBase(t, env.chromiumA, env.baseCommit, env.trackedPath, "patch-v4\n")
|
||||
writeFile(t, filepath.Join(collab, "chromium_patches", env.trackedPath), diffV4)
|
||||
commitRepo(t, collab, "feat: remote patch-v4 update")
|
||||
branch := strings.TrimSpace(runGit(t, collab, "symbolic-ref", "--short", "HEAD"))
|
||||
runGit(t, collab, "push", "origin", "HEAD:"+branch)
|
||||
|
||||
runBdev(t, env.chromiumA, "pull", "origin")
|
||||
assertFileContains(t, filepath.Join(env.chromiumA, env.trackedPath), "patch-v4")
|
||||
|
||||
runBdev(t, env.chromiumB, "clone", "--patches-repo", env.patchesRepo, "--verify-base", "--clean", "--name", "checkout-b")
|
||||
assertFileContains(t, filepath.Join(env.chromiumB, env.trackedPath), "patch-v4")
|
||||
statusB := readStatus(t, env.chromiumB)
|
||||
if statusB.Ahead != 0 || statusB.Synced == 0 {
|
||||
t.Fatalf("expected checkout-b to have clean/synced clone state, got %#v", statusB)
|
||||
}
|
||||
}
|
||||
|
||||
func setupScenario(t *testing.T) *scenario {
|
||||
t.Helper()
|
||||
|
||||
root := t.TempDir()
|
||||
patchesRemote := filepath.Join(root, "patches-remote.git")
|
||||
chromiumA := filepath.Join(root, "chromium-a")
|
||||
chromiumB := filepath.Join(root, "chromium-b")
|
||||
patchesRepo := filepath.Join(root, "patches")
|
||||
trackedPath := filepath.ToSlash(filepath.Join("chrome", "app", "test.txt"))
|
||||
newPath := filepath.ToSlash(filepath.Join("chrome", "browser", "new_file.txt"))
|
||||
|
||||
runGit(t, root, "init", "--bare", patchesRemote)
|
||||
setupChromiumRepo(t, chromiumA)
|
||||
|
||||
writeFile(t, filepath.Join(chromiumA, trackedPath), "base\n")
|
||||
runGit(t, chromiumA, "add", "-A")
|
||||
runGit(t, chromiumA, "commit", "-m", "base")
|
||||
baseCommit := strings.TrimSpace(runGit(t, chromiumA, "rev-parse", "HEAD"))
|
||||
|
||||
diffV1 := buildDiffFromBase(t, chromiumA, baseCommit, trackedPath, "patch-v1\n")
|
||||
|
||||
runGit(t, root, "clone", patchesRemote, patchesRepo)
|
||||
configRepo(t, patchesRepo)
|
||||
writeFile(t, filepath.Join(patchesRepo, "BASE_COMMIT"), baseCommit+"\n")
|
||||
writeFile(t, filepath.Join(patchesRepo, "CHROMIUM_VERSION"), "MAJOR=145\nMINOR=0\nBUILD=7632\nPATCH=45\n")
|
||||
writeFile(t, filepath.Join(patchesRepo, "chromium_patches", trackedPath), diffV1)
|
||||
commitRepo(t, patchesRepo, "seed patches")
|
||||
branch := strings.TrimSpace(runGit(t, patchesRepo, "symbolic-ref", "--short", "HEAD"))
|
||||
runGit(t, patchesRepo, "push", "-u", "origin", "HEAD:"+branch)
|
||||
|
||||
runGit(t, root, "clone", chromiumA, chromiumB)
|
||||
configRepo(t, chromiumB)
|
||||
|
||||
return &scenario{
|
||||
root: root,
|
||||
baseCommit: baseCommit,
|
||||
patchesRemote: patchesRemote,
|
||||
patchesRepo: patchesRepo,
|
||||
chromiumA: chromiumA,
|
||||
chromiumB: chromiumB,
|
||||
trackedPath: trackedPath,
|
||||
newPath: newPath,
|
||||
}
|
||||
}
|
||||
|
||||
func setupChromiumRepo(t *testing.T, dir string) {
|
||||
t.Helper()
|
||||
if err := os.MkdirAll(filepath.Join(dir, "chrome"), 0o755); err != nil {
|
||||
t.Fatalf("mkdir chrome: %v", err)
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Join(dir, "base"), 0o755); err != nil {
|
||||
t.Fatalf("mkdir base: %v", err)
|
||||
}
|
||||
writeFile(t, filepath.Join(dir, "base", ".keep"), "marker\n")
|
||||
runGit(t, dir, "init")
|
||||
configRepo(t, dir)
|
||||
}
|
||||
|
||||
func buildDiffFromBase(t *testing.T, repo, base, relPath, content string) string {
|
||||
t.Helper()
|
||||
abs := filepath.Join(repo, relPath)
|
||||
original := mustRead(t, abs)
|
||||
writeFile(t, abs, content)
|
||||
diff := runGit(t, repo, "diff", "--full-index", base, "--", relPath)
|
||||
writeFile(t, abs, original)
|
||||
if strings.TrimSpace(diff) == "" {
|
||||
t.Fatalf("expected non-empty diff for %s", relPath)
|
||||
}
|
||||
return diff
|
||||
}
|
||||
|
||||
func readStatus(t *testing.T, chromiumDir string) statusJSON {
|
||||
t.Helper()
|
||||
raw := runBdev(t, chromiumDir, "status", "--json")
|
||||
var s statusJSON
|
||||
if err := json.Unmarshal([]byte(raw), &s); err != nil {
|
||||
t.Fatalf("failed to parse status json: %v\nraw=%s", err, raw)
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func runBdev(t *testing.T, dir string, args ...string) string {
|
||||
t.Helper()
|
||||
cmd := exec.Command(bdevBinary, args...)
|
||||
cmd.Dir = dir
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
t.Fatalf("bdev %v failed: %v\n%s", args, err, string(out))
|
||||
}
|
||||
return string(out)
|
||||
}
|
||||
|
||||
func runGit(t *testing.T, dir string, args ...string) string {
|
||||
t.Helper()
|
||||
cmd := exec.Command("git", args...)
|
||||
cmd.Dir = dir
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
t.Fatalf("git %v failed: %v\n%s", args, err, string(out))
|
||||
}
|
||||
return string(out)
|
||||
}
|
||||
|
||||
func commitRepo(t *testing.T, dir, message string) {
|
||||
t.Helper()
|
||||
runGit(t, dir, "add", "-A")
|
||||
runGit(t, dir, "commit", "-m", message)
|
||||
}
|
||||
|
||||
func configRepo(t *testing.T, dir string) {
|
||||
t.Helper()
|
||||
runGit(t, dir, "config", "user.email", "bdev-e2e@example.com")
|
||||
runGit(t, dir, "config", "user.name", "bdev e2e")
|
||||
}
|
||||
|
||||
func writeFile(t *testing.T, path, content string) {
|
||||
t.Helper()
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
t.Fatalf("mkdir %s: %v", path, err)
|
||||
}
|
||||
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
|
||||
t.Fatalf("write %s: %v", path, err)
|
||||
}
|
||||
}
|
||||
|
||||
func mustRead(t *testing.T, path string) string {
|
||||
t.Helper()
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("read %s: %v", path, err)
|
||||
}
|
||||
return string(data)
|
||||
}
|
||||
|
||||
func assertContains(t *testing.T, output, want string) {
|
||||
t.Helper()
|
||||
if !strings.Contains(output, want) {
|
||||
t.Fatalf("expected output to contain %q\noutput:\n%s", want, output)
|
||||
}
|
||||
}
|
||||
|
||||
func assertFileContains(t *testing.T, path, want string) {
|
||||
t.Helper()
|
||||
content := mustRead(t, path)
|
||||
if !strings.Contains(content, want) {
|
||||
t.Fatalf("expected %s to contain %q\ncontent:\n%s", path, want, content)
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,10 @@
|
||||
module bdev
|
||||
module github.com/browseros-ai/BrowserOS/packages/browseros/tools/bdev
|
||||
|
||||
go 1.25.7
|
||||
go 1.25.0
|
||||
|
||||
require (
|
||||
github.com/charmbracelet/lipgloss v1.1.0
|
||||
github.com/spf13/cobra v1.10.2
|
||||
golang.org/x/sync v0.19.0
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
)
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
|
||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
|
||||
github.com/aymanbagabas/go-udiff v0.2.0 h1:TK0fH4MteXUDspT88n8CKzvK0X9O2xu9yQjWpi6yML8=
|
||||
github.com/aymanbagabas/go-udiff v0.2.0/go.mod h1:RE4Ex0qsGkTAJoQdQQCA0uG+nAzJO/pI/QwceO5fgrA=
|
||||
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs=
|
||||
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk=
|
||||
github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=
|
||||
@@ -8,6 +10,8 @@ github.com/charmbracelet/x/ansi v0.8.0 h1:9GTq3xq9caJW8ZrBTe0LIe2fvfLR/bYXKTx2ll
|
||||
github.com/charmbracelet/x/ansi v0.8.0/go.mod h1:wdYl/ONOLHLIVmQaxbIYEC/cRKOQyjTkowiI4blgS9Q=
|
||||
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8=
|
||||
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs=
|
||||
github.com/charmbracelet/x/exp/golden v0.0.0-20240806155701-69247e0abc2a h1:G99klV19u0QnhiizODirwVksQB91TJKV/UaTnACcG30=
|
||||
github.com/charmbracelet/x/exp/golden v0.0.0-20240806155701-69247e0abc2a/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U=
|
||||
github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ=
|
||||
github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
|
||||
@@ -34,8 +38,6 @@ github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJu
|
||||
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||
golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561 h1:MDc5xs78ZrZr3HMQugiXOAkSZtfTpbJLDr/lwfgO53E=
|
||||
golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561/go.mod h1:cyybsKvd6eL0RnXn6p/Grxp8F5bW7iYuBgsNCOHpMYE=
|
||||
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
|
||||
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc=
|
||||
golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
|
||||
67
packages/browseros/tools/bdev/internal/app/app.go
Normal file
67
packages/browseros/tools/bdev/internal/app/app.go
Normal file
@@ -0,0 +1,67 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/browseros-ai/BrowserOS/packages/browseros/tools/bdev/internal/repo"
|
||||
"github.com/browseros-ai/BrowserOS/packages/browseros/tools/bdev/internal/workspace"
|
||||
)
|
||||
|
||||
type App struct {
|
||||
JSON bool
|
||||
Verbose bool
|
||||
CWD string
|
||||
Config *workspace.Config
|
||||
Registry *workspace.Registry
|
||||
}
|
||||
|
||||
func Load(jsonOut bool, verbose bool, cwd string) (*App, error) {
|
||||
if cwd == "" {
|
||||
var err error
|
||||
cwd, err = os.Getwd()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
cfg, err := workspace.LoadConfig()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
reg, err := workspace.LoadRegistry()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &App{
|
||||
JSON: jsonOut,
|
||||
Verbose: verbose,
|
||||
CWD: filepath.Clean(cwd),
|
||||
Config: cfg,
|
||||
Registry: reg,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (a *App) Save() error {
|
||||
if err := workspace.SaveConfig(a.Config); err != nil {
|
||||
return err
|
||||
}
|
||||
return workspace.SaveRegistry(a.Registry)
|
||||
}
|
||||
|
||||
func (a *App) ResolveWorkspace(name string, src string) (workspace.Entry, error) {
|
||||
return workspace.Resolve(a.Registry, name, a.CWD, src)
|
||||
}
|
||||
|
||||
func (a *App) RepoInfo() (*repo.Info, error) {
|
||||
if a.Config.PatchesRepo == "" {
|
||||
discovered, err := repo.Discover(a.CWD)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf(
|
||||
`patches repo is not configured; run "bdev add <name> <path> --patches-repo <repo>" from the browseros repo once`,
|
||||
)
|
||||
}
|
||||
return repo.Load(discovered)
|
||||
}
|
||||
return repo.Load(a.Config.PatchesRepo)
|
||||
}
|
||||
@@ -1,57 +0,0 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func ReadBaseCommit(patchesRepo string) (string, error) {
|
||||
path := filepath.Join(patchesRepo, "BASE_COMMIT")
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return "", fmt.Errorf("BASE_COMMIT not found in %s — create it with the Chromium commit hash", patchesRepo)
|
||||
}
|
||||
return "", fmt.Errorf("reading BASE_COMMIT: %w", err)
|
||||
}
|
||||
commit := strings.TrimSpace(string(data))
|
||||
if commit == "" {
|
||||
return "", fmt.Errorf("BASE_COMMIT is empty in %s", patchesRepo)
|
||||
}
|
||||
return commit, nil
|
||||
}
|
||||
|
||||
func ReadChromiumVersion(patchesRepo string) (string, error) {
|
||||
path := filepath.Join(patchesRepo, "CHROMIUM_VERSION")
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return "", nil
|
||||
}
|
||||
return "", fmt.Errorf("reading CHROMIUM_VERSION: %w", err)
|
||||
}
|
||||
|
||||
vars := make(map[string]string)
|
||||
for _, line := range strings.Split(string(data), "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
parts := strings.SplitN(line, "=", 2)
|
||||
if len(parts) == 2 {
|
||||
vars[strings.TrimSpace(parts[0])] = strings.TrimSpace(parts[1])
|
||||
}
|
||||
}
|
||||
|
||||
major := vars["MAJOR"]
|
||||
minor := vars["MINOR"]
|
||||
build := vars["BUILD"]
|
||||
patch := vars["PATCH"]
|
||||
|
||||
if major == "" {
|
||||
return "", nil
|
||||
}
|
||||
return fmt.Sprintf("%s.%s.%s.%s", major, minor, build, patch), nil
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
Name string `yaml:"name"`
|
||||
PatchesRepo string `yaml:"patches_repo"`
|
||||
}
|
||||
|
||||
func ReadConfig(brosDir string) (*Config, error) {
|
||||
data, err := os.ReadFile(filepath.Join(brosDir, "config.yaml"))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("reading config: %w", err)
|
||||
}
|
||||
var cfg Config
|
||||
if err := yaml.Unmarshal(data, &cfg); err != nil {
|
||||
return nil, fmt.Errorf("parsing config.yaml: %w", err)
|
||||
}
|
||||
return &cfg, nil
|
||||
}
|
||||
|
||||
func WriteConfig(brosDir string, cfg *Config) error {
|
||||
if err := os.MkdirAll(brosDir, 0o755); err != nil {
|
||||
return fmt.Errorf("creating .bros directory: %w", err)
|
||||
}
|
||||
data, err := yaml.Marshal(cfg)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshaling config: %w", err)
|
||||
}
|
||||
path := filepath.Join(brosDir, "config.yaml")
|
||||
if err := os.WriteFile(path, data, 0o644); err != nil {
|
||||
return fmt.Errorf("writing config.yaml: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -1,101 +0,0 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
const BrosDirName = ".bros"
|
||||
|
||||
// Context holds everything needed for an operation.
|
||||
type Context struct {
|
||||
Config *Config
|
||||
State *State
|
||||
ChromiumDir string // Absolute path to chromium checkout (parent of .bros/)
|
||||
BrosDir string // Absolute path to .bros/
|
||||
PatchesRepo string // Absolute path to patches repo root
|
||||
PatchesDir string // Absolute path to chromium_patches/
|
||||
BaseCommit string
|
||||
ChromiumVersion string
|
||||
}
|
||||
|
||||
// FindBrosDir walks up from cwd to find the nearest .bros/ directory.
|
||||
func FindBrosDir() (string, error) {
|
||||
dir, err := os.Getwd()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("getting cwd: %w", err)
|
||||
}
|
||||
|
||||
for {
|
||||
candidate := filepath.Join(dir, BrosDirName)
|
||||
if info, err := os.Stat(candidate); err == nil && info.IsDir() {
|
||||
return dir, nil
|
||||
}
|
||||
parent := filepath.Dir(dir)
|
||||
if parent == dir {
|
||||
break
|
||||
}
|
||||
dir = parent
|
||||
}
|
||||
|
||||
return "", fmt.Errorf("not a bdev checkout (no .bros/ found in any parent directory)")
|
||||
}
|
||||
|
||||
// LoadContext loads config, state, and patches repo info.
|
||||
func LoadContext() (*Context, error) {
|
||||
chromiumDir, err := FindBrosDir()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
brosDir := filepath.Join(chromiumDir, BrosDirName)
|
||||
|
||||
cfg, err := ReadConfig(brosDir)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("loading config: %w", err)
|
||||
}
|
||||
|
||||
state, err := ReadState(brosDir)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("loading state: %w", err)
|
||||
}
|
||||
|
||||
patchesRepo := cfg.PatchesRepo
|
||||
if !filepath.IsAbs(patchesRepo) {
|
||||
patchesRepo = filepath.Join(chromiumDir, patchesRepo)
|
||||
}
|
||||
|
||||
patchesDir := filepath.Join(patchesRepo, "chromium_patches")
|
||||
if _, err := os.Stat(patchesDir); err != nil {
|
||||
return nil, fmt.Errorf("patches directory not found: %s", patchesDir)
|
||||
}
|
||||
|
||||
baseCommit, err := ReadBaseCommit(patchesRepo)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
chromiumVersion, _ := ReadChromiumVersion(patchesRepo)
|
||||
|
||||
return &Context{
|
||||
Config: cfg,
|
||||
State: state,
|
||||
ChromiumDir: chromiumDir,
|
||||
BrosDir: brosDir,
|
||||
PatchesRepo: patchesRepo,
|
||||
PatchesDir: patchesDir,
|
||||
BaseCommit: baseCommit,
|
||||
ChromiumVersion: chromiumVersion,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// LooksLikeChromium checks if a directory looks like a Chromium source tree.
|
||||
func LooksLikeChromium(dir string) bool {
|
||||
markers := []string{"chrome", "base", ".git"}
|
||||
for _, m := range markers {
|
||||
if _, err := os.Stat(filepath.Join(dir, m)); err != nil {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -1,50 +0,0 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
type State struct {
|
||||
LastPull *SyncEvent `yaml:"last_pull,omitempty"`
|
||||
LastPush *SyncEvent `yaml:"last_push,omitempty"`
|
||||
}
|
||||
|
||||
type SyncEvent struct {
|
||||
PatchesRepoRev string `yaml:"patches_repo_rev"`
|
||||
BaseCommit string `yaml:"base_commit,omitempty"`
|
||||
Timestamp time.Time `yaml:"timestamp"`
|
||||
FileCount int `yaml:"file_count"`
|
||||
}
|
||||
|
||||
func ReadState(brosDir string) (*State, error) {
|
||||
path := filepath.Join(brosDir, "state.yaml")
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return &State{}, nil
|
||||
}
|
||||
return nil, fmt.Errorf("reading state: %w", err)
|
||||
}
|
||||
var s State
|
||||
if err := yaml.Unmarshal(data, &s); err != nil {
|
||||
return nil, fmt.Errorf("parsing state.yaml: %w", err)
|
||||
}
|
||||
return &s, nil
|
||||
}
|
||||
|
||||
func WriteState(brosDir string, s *State) error {
|
||||
data, err := yaml.Marshal(s)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshaling state: %w", err)
|
||||
}
|
||||
path := filepath.Join(brosDir, "state.yaml")
|
||||
if err := os.WriteFile(path, data, 0o644); err != nil {
|
||||
return fmt.Errorf("writing state.yaml: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
410
packages/browseros/tools/bdev/internal/engine/apply.go
Normal file
410
packages/browseros/tools/bdev/internal/engine/apply.go
Normal file
@@ -0,0 +1,410 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/browseros-ai/BrowserOS/packages/browseros/tools/bdev/internal/git"
|
||||
"github.com/browseros-ai/BrowserOS/packages/browseros/tools/bdev/internal/patch"
|
||||
"github.com/browseros-ai/BrowserOS/packages/browseros/tools/bdev/internal/repo"
|
||||
"github.com/browseros-ai/BrowserOS/packages/browseros/tools/bdev/internal/resolve"
|
||||
"github.com/browseros-ai/BrowserOS/packages/browseros/tools/bdev/internal/workspace"
|
||||
)
|
||||
|
||||
type ApplyOptions struct {
|
||||
Workspace workspace.Entry
|
||||
Repo *repo.Info
|
||||
Reset bool
|
||||
ChangedRef string
|
||||
RangeEnd string
|
||||
Filters []string
|
||||
Mode string
|
||||
}
|
||||
|
||||
type ApplyResult struct {
|
||||
Workspace string `json:"workspace"`
|
||||
Mode string `json:"mode"`
|
||||
BaseCommit string `json:"base_commit"`
|
||||
RepoRev string `json:"repo_rev"`
|
||||
Applied []string `json:"applied"`
|
||||
ResetPaths []string `json:"reset_paths"`
|
||||
Orphaned []string `json:"orphaned,omitempty"`
|
||||
Conflicts []resolve.Operation `json:"conflicts,omitempty"`
|
||||
}
|
||||
|
||||
func Apply(ctx context.Context, opts ApplyOptions) (*ApplyResult, error) {
|
||||
repoRev, err := git.HeadRev(ctx, opts.Repo.Root)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ops, orphaned, err := buildApplyOperations(ctx, opts)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result := &ApplyResult{
|
||||
Workspace: opts.Workspace.Name,
|
||||
Mode: applyMode(opts),
|
||||
BaseCommit: opts.Repo.BaseCommit,
|
||||
RepoRev: repoRev,
|
||||
Orphaned: orphaned,
|
||||
}
|
||||
if len(ops) == 0 {
|
||||
if err := markApplyComplete(opts.Workspace.Path, opts.Repo.BaseCommit, repoRev); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := clearResolveState(opts.Workspace.Path); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
next, err := applyOperationRange(ctx, opts.Workspace, opts.Repo, ops, 0, nil, nil, result)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if next < len(ops) {
|
||||
return result, nil
|
||||
}
|
||||
if err := markApplyComplete(opts.Workspace.Path, opts.Repo.BaseCommit, repoRev); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := clearResolveState(opts.Workspace.Path); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func Continue(ctx context.Context, ws workspace.Entry) (*ApplyResult, error) {
|
||||
state, err := resolve.Load(ws.Path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
repoInfo, err := repo.Load(state.RepoRoot)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
current, err := state.CurrentOperation()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := verifyResolved(ctx, ws.Path, repoInfo, current, state.BaseCommit); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
state.Resolved = append(state.Resolved, current.ChromiumPath)
|
||||
result := &ApplyResult{
|
||||
Workspace: ws.Name,
|
||||
Mode: state.Mode,
|
||||
BaseCommit: state.BaseCommit,
|
||||
RepoRev: state.RepoRev,
|
||||
Applied: append([]string{}, state.Resolved...),
|
||||
Conflicts: nil,
|
||||
}
|
||||
next, err := applyOperationRange(ctx, ws, repoInfo, state.Operations, state.Current+1, state.Resolved, state.Skipped, result)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if next >= len(state.Operations) && len(result.Conflicts) == 0 {
|
||||
if err := markApplyComplete(ws.Path, state.BaseCommit, state.RepoRev); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := resolve.Delete(ws.Path); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func Skip(ctx context.Context, ws workspace.Entry) (*ApplyResult, error) {
|
||||
state, err := resolve.Load(ws.Path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
repoInfo, err := repo.Load(state.RepoRoot)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
current, err := state.CurrentOperation()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
state.Skipped = append(state.Skipped, current.ChromiumPath)
|
||||
result := &ApplyResult{
|
||||
Workspace: ws.Name,
|
||||
Mode: state.Mode,
|
||||
BaseCommit: state.BaseCommit,
|
||||
RepoRev: state.RepoRev,
|
||||
Applied: append([]string{}, state.Resolved...),
|
||||
}
|
||||
next, err := applyOperationRange(ctx, ws, repoInfo, state.Operations, state.Current+1, state.Resolved, state.Skipped, result)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if next >= len(state.Operations) && len(result.Conflicts) == 0 {
|
||||
if err := markApplyComplete(ws.Path, state.BaseCommit, state.RepoRev); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := resolve.Delete(ws.Path); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func Abort(ctx context.Context, ws workspace.Entry) error {
|
||||
state, err := resolve.Load(ws.Path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for idx := 0; idx < len(state.Operations); idx++ {
|
||||
op := state.Operations[idx]
|
||||
if op.OldPath != "" {
|
||||
if err := git.ResetPathToCommit(ctx, ws.Path, state.BaseCommit, op.OldPath); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := git.ResetPathToCommit(ctx, ws.Path, state.BaseCommit, op.ChromiumPath); err != nil {
|
||||
return err
|
||||
}
|
||||
if op.RejectPath != "" {
|
||||
_ = os.Remove(op.RejectPath)
|
||||
}
|
||||
}
|
||||
workspaceState, err := workspace.LoadState(ws.Path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := resolve.Delete(ws.Path); err != nil {
|
||||
return err
|
||||
}
|
||||
if workspaceState.PendingStash == "" {
|
||||
return nil
|
||||
}
|
||||
if err := git.StashPop(ctx, ws.Path, workspaceState.PendingStash); err != nil {
|
||||
return err
|
||||
}
|
||||
workspaceState.PendingStash = ""
|
||||
return workspace.SaveState(ws.Path, workspaceState)
|
||||
}
|
||||
|
||||
func buildApplyOperations(ctx context.Context, opts ApplyOptions) ([]resolve.Operation, []string, error) {
|
||||
repoSet, err := patch.LoadRepoPatchSet(opts.Repo.PatchesDir, opts.Filters)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
switch {
|
||||
case opts.Reset:
|
||||
return operationsFromPatchSet(repoSet), nil, nil
|
||||
case opts.ChangedRef != "":
|
||||
changes, err := repoPatchChanges(ctx, opts.Repo, opts.ChangedRef, opts.RangeEnd)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
return operationsFromChanges(repoSet, changes, opts.Filters), nil, nil
|
||||
default:
|
||||
localSet, err := patch.BuildWorkingTreePatchSet(ctx, opts.Workspace.Path, opts.Repo.BaseCommit, opts.Filters)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
var ops []resolve.Operation
|
||||
var orphaned []string
|
||||
for _, delta := range patch.Compare(repoSet, localSet) {
|
||||
switch delta.Kind {
|
||||
case patch.NeedsApply, patch.NeedsUpdate:
|
||||
ops = append(ops, operationFromPatch(*delta.Repo))
|
||||
case patch.Orphaned:
|
||||
orphaned = append(orphaned, delta.Path)
|
||||
}
|
||||
}
|
||||
return ops, orphaned, nil
|
||||
}
|
||||
}
|
||||
|
||||
func applyMode(opts ApplyOptions) string {
|
||||
switch {
|
||||
case opts.Mode != "":
|
||||
return opts.Mode
|
||||
case opts.Reset:
|
||||
return "reset"
|
||||
case opts.ChangedRef != "":
|
||||
return "changed"
|
||||
default:
|
||||
return "incremental"
|
||||
}
|
||||
}
|
||||
|
||||
func applyOperationRange(
|
||||
ctx context.Context,
|
||||
ws workspace.Entry,
|
||||
repoInfo *repo.Info,
|
||||
ops []resolve.Operation,
|
||||
start int,
|
||||
resolved []string,
|
||||
skipped []string,
|
||||
result *ApplyResult,
|
||||
) (int, error) {
|
||||
repoSet, err := patch.LoadRepoPatchSet(repoInfo.PatchesDir, nil)
|
||||
if err != nil {
|
||||
return start, err
|
||||
}
|
||||
for idx := start; idx < len(ops); idx++ {
|
||||
op := ops[idx]
|
||||
result.ResetPaths = append(result.ResetPaths, op.ChromiumPath)
|
||||
if op.OldPath != "" {
|
||||
if err := git.ResetPathToCommit(ctx, ws.Path, repoInfo.BaseCommit, op.OldPath); err != nil {
|
||||
return idx, err
|
||||
}
|
||||
}
|
||||
if err := git.ResetPathToCommit(ctx, ws.Path, repoInfo.BaseCommit, op.ChromiumPath); err != nil {
|
||||
return idx, err
|
||||
}
|
||||
patchFile, ok := repoSet[op.ChromiumPath]
|
||||
if ok {
|
||||
if err := applySingleOperation(ctx, ws.Path, patchFile); err != nil {
|
||||
op.RejectPath = rejectPath(ws.Path, op)
|
||||
op.Message = err.Error()
|
||||
ops[idx] = op
|
||||
state := &resolve.State{
|
||||
Workspace: ws.Path,
|
||||
RepoRoot: repoInfo.Root,
|
||||
BaseCommit: repoInfo.BaseCommit,
|
||||
RepoRev: result.RepoRev,
|
||||
Mode: result.Mode,
|
||||
Current: idx,
|
||||
Operations: ops,
|
||||
Resolved: append([]string{}, resolved...),
|
||||
Skipped: append([]string{}, skipped...),
|
||||
}
|
||||
if err := resolve.Save(ws.Path, state); err != nil {
|
||||
return idx, err
|
||||
}
|
||||
result.Conflicts = []resolve.Operation{op}
|
||||
return idx, nil
|
||||
}
|
||||
} else if op.Op == patch.OpDelete {
|
||||
if err := os.RemoveAll(filepath.Join(ws.Path, filepath.FromSlash(op.ChromiumPath))); err != nil {
|
||||
return idx, err
|
||||
}
|
||||
}
|
||||
result.Applied = append(result.Applied, op.ChromiumPath)
|
||||
}
|
||||
return len(ops), nil
|
||||
}
|
||||
|
||||
func applySingleOperation(ctx context.Context, workspacePath string, patchFile patch.FilePatch) error {
|
||||
switch {
|
||||
case patchFile.Op == patch.OpDelete:
|
||||
return os.RemoveAll(filepath.Join(workspacePath, filepath.FromSlash(patchFile.Path)))
|
||||
case patchFile.IsPureRename():
|
||||
from := filepath.Join(workspacePath, filepath.FromSlash(patchFile.OldPath))
|
||||
to := filepath.Join(workspacePath, filepath.FromSlash(patchFile.Path))
|
||||
if err := os.MkdirAll(filepath.Dir(to), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.Rename(from, to)
|
||||
case patchFile.Op == patch.OpBinary && len(patchFile.Content) == 0:
|
||||
return fmt.Errorf("binary markers are not directly applicable: %s", patchFile.Path)
|
||||
default:
|
||||
_, err := git.ApplyPatch(ctx, workspacePath, patchFile.Content)
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
func verifyResolved(ctx context.Context, workspacePath string, repoInfo *repo.Info, op resolve.Operation, base string) error {
|
||||
repoSet, err := patch.LoadRepoPatchSet(repoInfo.PatchesDir, []string{op.ChromiumPath})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
localSet, err := patch.BuildWorkingTreePatchSet(ctx, workspacePath, base, []string{op.ChromiumPath})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, delta := range patch.Compare(repoSet, localSet) {
|
||||
if delta.Path == op.ChromiumPath && delta.Kind == patch.UpToDate {
|
||||
if op.RejectPath != "" {
|
||||
_ = os.Remove(op.RejectPath)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("current conflict is not resolved yet for %s", op.ChromiumPath)
|
||||
}
|
||||
|
||||
func operationFromPatch(p patch.FilePatch) resolve.Operation {
|
||||
return resolve.Operation{
|
||||
ChromiumPath: p.Path,
|
||||
PatchRel: p.Path,
|
||||
Op: p.Op,
|
||||
OldPath: p.OldPath,
|
||||
}
|
||||
}
|
||||
|
||||
func operationsFromPatchSet(set patch.PatchSet) []resolve.Operation {
|
||||
paths := patch.ScopeFromSet(set)
|
||||
ops := make([]resolve.Operation, 0, len(paths))
|
||||
for _, rel := range paths {
|
||||
ops = append(ops, operationFromPatch(set[rel]))
|
||||
}
|
||||
return ops
|
||||
}
|
||||
|
||||
func operationsFromChanges(repoSet patch.PatchSet, changes []git.FileChange, filters []string) []resolve.Operation {
|
||||
var ops []resolve.Operation
|
||||
for _, change := range changes {
|
||||
rel := normalizeChangedPatchPath(change.Path)
|
||||
if !patch.PathMatches(rel, filters) {
|
||||
continue
|
||||
}
|
||||
if patchFile, ok := repoSet[rel]; ok {
|
||||
ops = append(ops, operationFromPatch(patchFile))
|
||||
continue
|
||||
}
|
||||
ops = append(ops, resolve.Operation{
|
||||
ChromiumPath: rel,
|
||||
PatchRel: rel,
|
||||
Op: patch.OpDelete,
|
||||
OldPath: normalizeChangedPatchPath(change.OldPath),
|
||||
})
|
||||
}
|
||||
return ops
|
||||
}
|
||||
|
||||
func repoPatchChanges(ctx context.Context, repoInfo *repo.Info, ref string, rangeEnd string) ([]git.FileChange, error) {
|
||||
pathspecs := []string{"chromium_patches"}
|
||||
if rangeEnd == "" {
|
||||
return git.DiffTreeNameStatus(ctx, repoInfo.Root, ref, pathspecs)
|
||||
}
|
||||
return git.DiffNameStatusBetween(ctx, repoInfo.Root, ref, rangeEnd, pathspecs)
|
||||
}
|
||||
|
||||
func rejectPath(workspacePath string, op resolve.Operation) string {
|
||||
candidate := patch.RejectPath(workspacePath, op.ChromiumPath)
|
||||
if _, err := os.Stat(candidate); err == nil {
|
||||
return candidate
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func normalizeChangedPatchPath(path string) string {
|
||||
return strings.TrimPrefix(patch.NormalizeChromiumPath(path), "chromium_patches/")
|
||||
}
|
||||
|
||||
func clearResolveState(workspacePath string) error {
|
||||
if resolve.Exists(workspacePath) {
|
||||
return resolve.Delete(workspacePath)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func markApplyComplete(workspacePath string, baseCommit string, repoRev string) error {
|
||||
state, err := workspace.LoadState(workspacePath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
state.BaseCommit = baseCommit
|
||||
state.LastApplyRev = repoRev
|
||||
state.LastApplyAt = time.Now().UTC()
|
||||
return workspace.SaveState(workspacePath, state)
|
||||
}
|
||||
@@ -1,120 +0,0 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"bdev/internal/config"
|
||||
"bdev/internal/git"
|
||||
"bdev/internal/patch"
|
||||
)
|
||||
|
||||
type CloneOpts struct {
|
||||
VerifyBase bool
|
||||
Clean bool
|
||||
DryRun bool
|
||||
}
|
||||
|
||||
func Clone(ctx *config.Context, opts CloneOpts) (*patch.PullResult, error) {
|
||||
result := &patch.PullResult{}
|
||||
|
||||
// Verify HEAD matches BASE if requested
|
||||
if opts.VerifyBase {
|
||||
head, err := git.RevParse(ctx.ChromiumDir, "HEAD")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("clone: getting HEAD: %w", err)
|
||||
}
|
||||
base, err := git.RevParse(ctx.ChromiumDir, ctx.BaseCommit)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("clone: resolving BASE_COMMIT %s: %w", ctx.BaseCommit, err)
|
||||
}
|
||||
if head != base {
|
||||
return nil, fmt.Errorf("clone: HEAD (%s) does not match BASE_COMMIT (%s) — use --verify-base=false to skip", head[:12], base[:12])
|
||||
}
|
||||
}
|
||||
|
||||
// Clean: reset all modified files to base before applying
|
||||
if opts.Clean && !opts.DryRun {
|
||||
nameStatus, err := git.DiffNameStatus(ctx.ChromiumDir, ctx.BaseCommit)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("clone: discovering local changes: %w", err)
|
||||
}
|
||||
|
||||
if len(nameStatus) > 0 {
|
||||
var checkoutFiles []string
|
||||
for path := range nameStatus {
|
||||
if git.FileExistsInCommit(ctx.ChromiumDir, ctx.BaseCommit, path) {
|
||||
checkoutFiles = append(checkoutFiles, path)
|
||||
} else {
|
||||
// File doesn't exist in base — remove it
|
||||
_ = os.Remove(filepath.Join(ctx.ChromiumDir, path))
|
||||
}
|
||||
}
|
||||
if len(checkoutFiles) > 0 {
|
||||
if err := git.CheckoutFiles(ctx.ChromiumDir, ctx.BaseCommit, checkoutFiles); err != nil {
|
||||
return nil, fmt.Errorf("clone: resetting to base: %w", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Read all patches from repo
|
||||
repoPatchSet, err := patch.ReadPatchSet(ctx.PatchesDir)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("clone: reading patches: %w", err)
|
||||
}
|
||||
|
||||
if opts.DryRun {
|
||||
for path, fp := range repoPatchSet.Patches {
|
||||
if fp.Op == patch.OpDeleted {
|
||||
result.Deleted = append(result.Deleted, path)
|
||||
} else {
|
||||
result.Applied = append(result.Applied, path)
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// Apply all patches
|
||||
for path, fp := range repoPatchSet.Patches {
|
||||
switch fp.Op {
|
||||
case patch.OpDeleted:
|
||||
target := filepath.Join(ctx.ChromiumDir, path)
|
||||
if _, err := os.Stat(target); err == nil {
|
||||
if err := os.Remove(target); err != nil {
|
||||
return nil, fmt.Errorf("clone: deleting %s: %w", path, err)
|
||||
}
|
||||
result.Deleted = append(result.Deleted, path)
|
||||
}
|
||||
|
||||
case patch.OpBinary:
|
||||
// Skip binary files with no content
|
||||
continue
|
||||
|
||||
default:
|
||||
if fp.Content == nil {
|
||||
continue
|
||||
}
|
||||
// Remove existing file if it's not in BASE (untracked new-file).
|
||||
// git diff can't see untracked files, so --clean misses them.
|
||||
if !git.FileExistsInCommit(ctx.ChromiumDir, ctx.BaseCommit, path) {
|
||||
_ = os.Remove(filepath.Join(ctx.ChromiumDir, path))
|
||||
}
|
||||
patchFile := filepath.Join(ctx.PatchesDir, path)
|
||||
conflict, err := git.Apply(ctx.ChromiumDir, fp.Content, patchFile)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("clone: applying %s: %w", path, err)
|
||||
}
|
||||
if conflict != nil {
|
||||
conflict.File = path
|
||||
conflict.RejectFile = path + ".rej"
|
||||
result.Conflicts = append(result.Conflicts, *conflict)
|
||||
} else {
|
||||
result.Applied = append(result.Applied, path)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
@@ -1,50 +0,0 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestCloneCleanAppliesPatchAndResetsLocalChanges(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctx := setupPullFixture(t)
|
||||
result, err := Clone(ctx, CloneOpts{
|
||||
Clean: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Clone: %v", err)
|
||||
}
|
||||
|
||||
if !contains(result.Applied, "foo.txt") {
|
||||
t.Fatalf("expected foo.txt to be applied, got %#v", result.Applied)
|
||||
}
|
||||
|
||||
foo := mustRead(t, filepath.Join(ctx.ChromiumDir, "foo.txt"))
|
||||
if strings.TrimSpace(foo) != "repo-version" {
|
||||
t.Fatalf("expected foo.txt to match patch repo, got %q", foo)
|
||||
}
|
||||
|
||||
orphan := mustRead(t, filepath.Join(ctx.ChromiumDir, "orphan.txt"))
|
||||
if strings.TrimSpace(orphan) != "orphan-base" {
|
||||
t.Fatalf("expected orphan.txt to be reset during clean clone, got %q", orphan)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCloneVerifyBaseRejectsMismatchedHead(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctx := setupPullFixture(t)
|
||||
writeFile(t, filepath.Join(ctx.ChromiumDir, "post_base.txt"), "new commit\n")
|
||||
runGit(t, ctx.ChromiumDir, "add", "post_base.txt")
|
||||
runGit(t, ctx.ChromiumDir, "commit", "-m", "move head")
|
||||
|
||||
_, err := Clone(ctx, CloneOpts{VerifyBase: true})
|
||||
if err == nil {
|
||||
t.Fatalf("expected verify-base failure when HEAD diverges from BASE_COMMIT")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "does not match BASE_COMMIT") {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
220
packages/browseros/tools/bdev/internal/engine/engine_test.go
Normal file
220
packages/browseros/tools/bdev/internal/engine/engine_test.go
Normal file
@@ -0,0 +1,220 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/browseros-ai/BrowserOS/packages/browseros/tools/bdev/internal/git"
|
||||
"github.com/browseros-ai/BrowserOS/packages/browseros/tools/bdev/internal/patch"
|
||||
"github.com/browseros-ai/BrowserOS/packages/browseros/tools/bdev/internal/repo"
|
||||
"github.com/browseros-ai/BrowserOS/packages/browseros/tools/bdev/internal/resolve"
|
||||
"github.com/browseros-ai/BrowserOS/packages/browseros/tools/bdev/internal/workspace"
|
||||
)
|
||||
|
||||
func TestAbortRevertsAppliedOpsAndRestoresPendingStash(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
workspacePath := initGitRepo(t)
|
||||
writeFile(t, filepath.Join(workspacePath, "a.txt"), "a\n")
|
||||
writeFile(t, filepath.Join(workspacePath, "b.txt"), "b\n")
|
||||
writeFile(t, filepath.Join(workspacePath, "local.txt"), "local\n")
|
||||
runGit(t, workspacePath, "add", "a.txt", "b.txt", "local.txt")
|
||||
runGit(t, workspacePath, "commit", "-m", "base")
|
||||
baseCommit := gitOutput(t, workspacePath, "rev-parse", "HEAD")
|
||||
|
||||
writeFile(t, filepath.Join(workspacePath, "local.txt"), "local changed\n")
|
||||
runGit(t, workspacePath, "stash", "push", "-m", "test stash", "-u", "--", "local.txt")
|
||||
stashRef := gitOutput(t, workspacePath, "stash", "list", "-1", "--format=%gd")
|
||||
if stashRef == "" {
|
||||
t.Fatalf("expected stash ref")
|
||||
}
|
||||
|
||||
if err := workspace.SaveState(workspacePath, &workspace.State{
|
||||
Version: 1,
|
||||
Workspace: workspacePath,
|
||||
BaseCommit: baseCommit,
|
||||
PendingStash: stashRef,
|
||||
}); err != nil {
|
||||
t.Fatalf("SaveState: %v", err)
|
||||
}
|
||||
|
||||
writeFile(t, filepath.Join(workspacePath, "a.txt"), "applied\n")
|
||||
writeFile(t, filepath.Join(workspacePath, "b.txt"), "conflict\n")
|
||||
if err := resolve.Save(workspacePath, &resolve.State{
|
||||
Workspace: workspacePath,
|
||||
RepoRoot: workspacePath,
|
||||
BaseCommit: baseCommit,
|
||||
Current: 1,
|
||||
Operations: []resolve.Operation{
|
||||
{ChromiumPath: "a.txt", PatchRel: "a.txt", Op: patch.OpModify},
|
||||
{ChromiumPath: "b.txt", PatchRel: "b.txt", Op: patch.OpModify},
|
||||
},
|
||||
}); err != nil {
|
||||
t.Fatalf("resolve.Save: %v", err)
|
||||
}
|
||||
|
||||
if err := Abort(ctx, workspace.Entry{Name: "ws", Path: workspacePath}); err != nil {
|
||||
t.Fatalf("Abort: %v", err)
|
||||
}
|
||||
|
||||
assertFile(t, filepath.Join(workspacePath, "a.txt"), "a\n")
|
||||
assertFile(t, filepath.Join(workspacePath, "b.txt"), "b\n")
|
||||
assertFile(t, filepath.Join(workspacePath, "local.txt"), "local changed\n")
|
||||
if resolve.Exists(workspacePath) {
|
||||
t.Fatalf("expected resolve state to be removed")
|
||||
}
|
||||
state, err := workspace.LoadState(workspacePath)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadState: %v", err)
|
||||
}
|
||||
if state.PendingStash != "" {
|
||||
t.Fatalf("expected pending stash cleared, got %q", state.PendingStash)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPublishReturnsHelpfulErrorWhenNothingChanged(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
repoRoot := initGitRepo(t)
|
||||
writeFile(t, filepath.Join(repoRoot, "BASE_COMMIT"), "base123\n")
|
||||
writeFile(t, filepath.Join(repoRoot, "chromium_patches", ".gitkeep"), "")
|
||||
runGit(t, repoRoot, "add", "BASE_COMMIT", "chromium_patches/.gitkeep")
|
||||
runGit(t, repoRoot, "commit", "-m", "repo init")
|
||||
|
||||
repoInfo, err := repo.Load(repoRoot)
|
||||
if err != nil {
|
||||
t.Fatalf("repo.Load: %v", err)
|
||||
}
|
||||
if _, err := Publish(ctx, repoInfo, "", ""); err == nil || !strings.Contains(err.Error(), "nothing to publish") {
|
||||
t.Fatalf("expected helpful no-op error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOperationsFromChangesNormalizesOldPath(t *testing.T) {
|
||||
ops := operationsFromChanges(nil, []git.FileChange{{
|
||||
Status: "R",
|
||||
Path: "chromium_patches/chrome/new.cc",
|
||||
OldPath: "chromium_patches/chrome/old.cc",
|
||||
}}, nil)
|
||||
|
||||
if len(ops) != 1 {
|
||||
t.Fatalf("expected 1 operation, got %d", len(ops))
|
||||
}
|
||||
if ops[0].ChromiumPath != "chrome/new.cc" {
|
||||
t.Fatalf("unexpected chromium path: %q", ops[0].ChromiumPath)
|
||||
}
|
||||
if ops[0].OldPath != "chrome/old.cc" {
|
||||
t.Fatalf("unexpected old path: %q", ops[0].OldPath)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSyncClearsPendingStashAfterSuccessfulNonRebaseRun(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
workspacePath := initGitRepo(t)
|
||||
writeFile(t, filepath.Join(workspacePath, "chrome", "browser.cc"), "base\n")
|
||||
runGit(t, workspacePath, "add", "chrome/browser.cc")
|
||||
runGit(t, workspacePath, "commit", "-m", "workspace base")
|
||||
baseCommit := gitOutput(t, workspacePath, "rev-parse", "HEAD")
|
||||
|
||||
remoteRepo := t.TempDir()
|
||||
runGit(t, remoteRepo, "init", "--bare")
|
||||
|
||||
repoRoot := initGitRepo(t)
|
||||
if err := os.MkdirAll(filepath.Join(repoRoot, "chromium_patches"), 0o755); err != nil {
|
||||
t.Fatalf("MkdirAll: %v", err)
|
||||
}
|
||||
writeFile(t, filepath.Join(repoRoot, "BASE_COMMIT"), baseCommit+"\n")
|
||||
runGit(t, repoRoot, "add", "BASE_COMMIT")
|
||||
runGit(t, repoRoot, "commit", "-m", "patch repo init")
|
||||
runGit(t, repoRoot, "remote", "add", "origin", remoteRepo)
|
||||
runGit(t, repoRoot, "push", "-u", "origin", "HEAD")
|
||||
repoHead := gitOutput(t, repoRoot, "rev-parse", "HEAD")
|
||||
|
||||
repoInfo, err := repo.Load(repoRoot)
|
||||
if err != nil {
|
||||
t.Fatalf("repo.Load: %v", err)
|
||||
}
|
||||
if err := workspace.SaveState(workspacePath, &workspace.State{
|
||||
Version: 1,
|
||||
Workspace: workspacePath,
|
||||
BaseCommit: baseCommit,
|
||||
LastSyncRev: repoHead,
|
||||
PendingStash: "stash@{42}",
|
||||
}); err != nil {
|
||||
t.Fatalf("SaveState: %v", err)
|
||||
}
|
||||
|
||||
result, err := Sync(ctx, SyncOptions{
|
||||
Workspace: workspace.Entry{Name: "ws", Path: workspacePath},
|
||||
Repo: repoInfo,
|
||||
Remote: "origin",
|
||||
Rebase: false,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Sync: %v", err)
|
||||
}
|
||||
if result.StashRef != "" {
|
||||
t.Fatalf("expected no new stash ref, got %q", result.StashRef)
|
||||
}
|
||||
|
||||
state, err := workspace.LoadState(workspacePath)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadState: %v", err)
|
||||
}
|
||||
if state.PendingStash != "" {
|
||||
t.Fatalf("expected pending stash to be cleared, got %q", state.PendingStash)
|
||||
}
|
||||
}
|
||||
|
||||
func initGitRepo(t *testing.T) string {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
runGit(t, dir, "init")
|
||||
runGit(t, dir, "config", "user.name", "Test User")
|
||||
runGit(t, dir, "config", "user.email", "test@example.com")
|
||||
return dir
|
||||
}
|
||||
|
||||
func runGit(t *testing.T, dir string, args ...string) {
|
||||
t.Helper()
|
||||
cmd := exec.Command("git", args...)
|
||||
cmd.Dir = dir
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
t.Fatalf("git %s: %v\n%s", strings.Join(args, " "), err, string(output))
|
||||
}
|
||||
}
|
||||
|
||||
func gitOutput(t *testing.T, dir string, args ...string) string {
|
||||
t.Helper()
|
||||
cmd := exec.Command("git", args...)
|
||||
cmd.Dir = dir
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
t.Fatalf("git %s: %v\n%s", strings.Join(args, " "), err, string(output))
|
||||
}
|
||||
return strings.TrimSpace(string(output))
|
||||
}
|
||||
|
||||
func writeFile(t *testing.T, path string, body string) {
|
||||
t.Helper()
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
t.Fatalf("MkdirAll: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(path, []byte(body), 0o644); err != nil {
|
||||
t.Fatalf("WriteFile: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func assertFile(t *testing.T, path string, want string) {
|
||||
t.Helper()
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile %s: %v", path, err)
|
||||
}
|
||||
if string(data) != want {
|
||||
t.Fatalf("unexpected file contents for %s: got %q want %q", path, string(data), want)
|
||||
}
|
||||
}
|
||||
119
packages/browseros/tools/bdev/internal/engine/extract.go
Normal file
119
packages/browseros/tools/bdev/internal/engine/extract.go
Normal file
@@ -0,0 +1,119 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/browseros-ai/BrowserOS/packages/browseros/tools/bdev/internal/git"
|
||||
"github.com/browseros-ai/BrowserOS/packages/browseros/tools/bdev/internal/patch"
|
||||
"github.com/browseros-ai/BrowserOS/packages/browseros/tools/bdev/internal/repo"
|
||||
"github.com/browseros-ai/BrowserOS/packages/browseros/tools/bdev/internal/workspace"
|
||||
)
|
||||
|
||||
type ExtractOptions struct {
|
||||
Workspace workspace.Entry
|
||||
Repo *repo.Info
|
||||
Commit string
|
||||
RangeStart string
|
||||
RangeEnd string
|
||||
Squash bool
|
||||
Base string
|
||||
Filters []string
|
||||
}
|
||||
|
||||
type ExtractResult struct {
|
||||
Workspace string `json:"workspace"`
|
||||
Mode string `json:"mode"`
|
||||
BaseCommit string `json:"base_commit"`
|
||||
Written []string `json:"written"`
|
||||
Deleted []string `json:"deleted"`
|
||||
}
|
||||
|
||||
func Extract(ctx context.Context, opts ExtractOptions) (*ExtractResult, error) {
|
||||
base := opts.Base
|
||||
if base == "" {
|
||||
base = opts.Repo.BaseCommit
|
||||
}
|
||||
var (
|
||||
set patch.PatchSet
|
||||
scope []string
|
||||
err error
|
||||
mode string
|
||||
)
|
||||
switch {
|
||||
case opts.Commit != "":
|
||||
mode = "commit"
|
||||
set, err = patch.BuildCommitPatchSet(ctx, opts.Workspace.Path, opts.Commit, opts.Base, opts.Filters)
|
||||
if err == nil {
|
||||
if opts.Base != "" {
|
||||
changes, err := git.DiffTreeNameStatus(ctx, opts.Workspace.Path, opts.Commit, opts.Filters)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
scope = changedScope(changes)
|
||||
} else {
|
||||
scope = patch.ScopeFromSet(set)
|
||||
}
|
||||
}
|
||||
case opts.RangeStart != "" && opts.RangeEnd != "":
|
||||
mode = "range"
|
||||
set, err = patch.BuildRangePatchSet(ctx, opts.Workspace.Path, opts.RangeStart, opts.RangeEnd, opts.Base, opts.Squash, opts.Filters)
|
||||
if err == nil {
|
||||
if opts.Base != "" || opts.Squash {
|
||||
changes, err := git.DiffNameStatusBetween(ctx, opts.Workspace.Path, opts.RangeStart, opts.RangeEnd, opts.Filters)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
scope = changedScope(changes)
|
||||
} else {
|
||||
scope = patch.ScopeFromSet(set)
|
||||
}
|
||||
}
|
||||
default:
|
||||
mode = "working-tree"
|
||||
set, err = patch.BuildWorkingTreePatchSet(ctx, opts.Workspace.Path, base, opts.Filters)
|
||||
if err == nil && len(opts.Filters) > 0 {
|
||||
scope = opts.Filters
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
written, deleted, err := patch.WriteRepoPatchSet(opts.Repo.PatchesDir, set, scope)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
state, err := workspace.LoadState(opts.Workspace.Path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
head, err := git.HeadRev(ctx, opts.Workspace.Path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
state.BaseCommit = opts.Repo.BaseCommit
|
||||
state.LastExtractRev = head
|
||||
state.LastExtractAt = time.Now().UTC()
|
||||
if err := workspace.SaveState(opts.Workspace.Path, state); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &ExtractResult{
|
||||
Workspace: opts.Workspace.Name,
|
||||
Mode: mode,
|
||||
BaseCommit: base,
|
||||
Written: written,
|
||||
Deleted: deleted,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func changedScope(changes []git.FileChange) []string {
|
||||
scope := make([]string, 0, len(changes))
|
||||
for _, change := range changes {
|
||||
rel := patch.NormalizeChromiumPath(change.Path)
|
||||
if patch.IsInternalPath(rel) {
|
||||
continue
|
||||
}
|
||||
scope = append(scope, rel)
|
||||
}
|
||||
return scope
|
||||
}
|
||||
45
packages/browseros/tools/bdev/internal/engine/publish.go
Normal file
45
packages/browseros/tools/bdev/internal/engine/publish.go
Normal file
@@ -0,0 +1,45 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/browseros-ai/BrowserOS/packages/browseros/tools/bdev/internal/git"
|
||||
"github.com/browseros-ai/BrowserOS/packages/browseros/tools/bdev/internal/repo"
|
||||
)
|
||||
|
||||
type PublishResult struct {
|
||||
Remote string `json:"remote"`
|
||||
Branch string `json:"branch"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
func Publish(ctx context.Context, repoInfo *repo.Info, remote string, message string) (*PublishResult, error) {
|
||||
if remote == "" {
|
||||
remote = "origin"
|
||||
}
|
||||
if message == "" {
|
||||
message = "chore: update chromium patches"
|
||||
}
|
||||
dirty, err := git.IsDirtyPaths(ctx, repoInfo.Root, []string{"chromium_patches"})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !dirty {
|
||||
return nil, fmt.Errorf("nothing to publish: chromium_patches has no uncommitted changes")
|
||||
}
|
||||
if err := git.AddPaths(ctx, repoInfo.Root, []string{"chromium_patches"}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := git.Commit(ctx, repoInfo.Root, message); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
branch, err := git.CurrentBranch(ctx, repoInfo.Root)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := git.Push(ctx, repoInfo.Root, remote, branch); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &PublishResult{Remote: remote, Branch: branch, Message: message}, nil
|
||||
}
|
||||
@@ -1,345 +0,0 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"bdev/internal/config"
|
||||
"bdev/internal/git"
|
||||
"bdev/internal/patch"
|
||||
)
|
||||
|
||||
type PullOpts struct {
|
||||
DryRun bool
|
||||
Files []string
|
||||
KeepLocalOnly bool
|
||||
}
|
||||
|
||||
func Pull(ctx *config.Context, opts PullOpts) (*patch.PullResult, error) {
|
||||
repoPatchSet, err := patch.ReadPatchSet(ctx.PatchesDir)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("pull: reading repo patches: %w", err)
|
||||
}
|
||||
|
||||
repoHead, err := git.HeadRev(ctx.PatchesRepo)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("pull: reading patches repo HEAD: %w", err)
|
||||
}
|
||||
|
||||
incrementalPaths, shouldUseIncremental, err := resolveIncrementalPaths(ctx, repoHead, opts.Files)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("pull: resolving incremental scope: %w", err)
|
||||
}
|
||||
|
||||
if shouldUseIncremental {
|
||||
result, err := incrementalPull(ctx, repoPatchSet, incrementalPaths, opts.DryRun)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sortPullResult(result)
|
||||
return result, nil
|
||||
}
|
||||
|
||||
result, err := fullPull(ctx, repoPatchSet, opts)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sortPullResult(result)
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func resolveIncrementalPaths(ctx *config.Context, repoHead string, filesFilter []string) ([]string, bool, error) {
|
||||
if len(filesFilter) > 0 {
|
||||
return nil, false, nil
|
||||
}
|
||||
|
||||
if ctx.State == nil || ctx.State.LastPull == nil {
|
||||
return nil, false, nil
|
||||
}
|
||||
|
||||
lastPull := ctx.State.LastPull
|
||||
if strings.TrimSpace(lastPull.PatchesRepoRev) == "" {
|
||||
return nil, false, nil
|
||||
}
|
||||
|
||||
if lastPull.BaseCommit != ctx.BaseCommit {
|
||||
return nil, false, nil
|
||||
}
|
||||
|
||||
if !git.CommitExists(ctx.PatchesRepo, lastPull.PatchesRepoRev) {
|
||||
return nil, false, nil
|
||||
}
|
||||
|
||||
if lastPull.PatchesRepoRev == repoHead {
|
||||
return []string{}, true, nil
|
||||
}
|
||||
|
||||
repoPaths, err := git.DiffChangedPathsBetween(
|
||||
ctx.PatchesRepo,
|
||||
lastPull.PatchesRepoRev,
|
||||
repoHead,
|
||||
"chromium_patches",
|
||||
)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
|
||||
seen := make(map[string]bool)
|
||||
for _, repoPath := range repoPaths {
|
||||
chromiumPath, ok := normalizeRepoPatchPath(repoPath)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
seen[chromiumPath] = true
|
||||
}
|
||||
|
||||
paths := make([]string, 0, len(seen))
|
||||
for p := range seen {
|
||||
paths = append(paths, p)
|
||||
}
|
||||
sort.Strings(paths)
|
||||
return paths, true, nil
|
||||
}
|
||||
|
||||
func normalizeRepoPatchPath(repoPath string) (string, bool) {
|
||||
p := filepath.ToSlash(strings.TrimSpace(repoPath))
|
||||
if !strings.HasPrefix(p, "chromium_patches/") {
|
||||
return "", false
|
||||
}
|
||||
|
||||
chromiumPath := strings.TrimPrefix(p, "chromium_patches/")
|
||||
chromiumPath = strings.TrimSuffix(chromiumPath, ".deleted")
|
||||
chromiumPath = strings.TrimSuffix(chromiumPath, ".binary")
|
||||
chromiumPath = strings.TrimSuffix(chromiumPath, ".rename")
|
||||
if chromiumPath == "" {
|
||||
return "", false
|
||||
}
|
||||
|
||||
return chromiumPath, true
|
||||
}
|
||||
|
||||
func incrementalPull(
|
||||
ctx *config.Context,
|
||||
repoPatchSet *patch.PatchSet,
|
||||
paths []string,
|
||||
dryRun bool,
|
||||
) (*patch.PullResult, error) {
|
||||
result := &patch.PullResult{}
|
||||
|
||||
for _, path := range paths {
|
||||
repoPatch, exists := repoPatchSet.Patches[path]
|
||||
if !exists {
|
||||
if !dryRun {
|
||||
if err := resetPathToBase(ctx, path); err != nil {
|
||||
return nil, fmt.Errorf("pull: reverting removed patch %s: %w", path, err)
|
||||
}
|
||||
}
|
||||
result.Reverted = append(result.Reverted, path)
|
||||
continue
|
||||
}
|
||||
|
||||
switch repoPatch.Op {
|
||||
case patch.OpDeleted:
|
||||
if !dryRun {
|
||||
if err := deletePath(ctx, path); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
result.Deleted = append(result.Deleted, path)
|
||||
case patch.OpBinary:
|
||||
result.Skipped = append(result.Skipped, path)
|
||||
default:
|
||||
if !dryRun {
|
||||
if err := resetPathToBase(ctx, path); err != nil {
|
||||
return nil, fmt.Errorf("pull: resetting %s to base: %w", path, err)
|
||||
}
|
||||
if err := applyRepoPatch(ctx, repoPatch, path, result); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
} else {
|
||||
result.Applied = append(result.Applied, path)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func fullPull(ctx *config.Context, repoPatchSet *patch.PatchSet, opts PullOpts) (*patch.PullResult, error) {
|
||||
result := &patch.PullResult{}
|
||||
|
||||
diffOutput, err := git.DiffFull(ctx.ChromiumDir, ctx.BaseCommit)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("pull: reading local diffs: %w", err)
|
||||
}
|
||||
|
||||
localPatchSet, err := patch.ParseUnifiedDiff(diffOutput)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("pull: parsing local diffs: %w", err)
|
||||
}
|
||||
|
||||
delta := patch.Compare(localPatchSet, repoPatchSet)
|
||||
if len(opts.Files) > 0 {
|
||||
delta = filterDelta(delta, opts.Files)
|
||||
}
|
||||
|
||||
if opts.DryRun {
|
||||
result.Applied = append(delta.NeedsUpdate, delta.NeedsApply...)
|
||||
result.Skipped = delta.UpToDate
|
||||
result.Deleted = delta.Deleted
|
||||
if opts.KeepLocalOnly {
|
||||
result.LocalOnly = delta.Orphaned
|
||||
} else {
|
||||
result.Reverted = delta.Orphaned
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
filesToReset := make([]string, 0, len(delta.NeedsUpdate)+len(delta.Orphaned))
|
||||
filesToReset = append(filesToReset, delta.NeedsUpdate...)
|
||||
if !opts.KeepLocalOnly {
|
||||
filesToReset = append(filesToReset, delta.Orphaned...)
|
||||
}
|
||||
for _, path := range filesToReset {
|
||||
if err := resetPathToBase(ctx, path); err != nil {
|
||||
return nil, fmt.Errorf("pull: resetting %s to base: %w", path, err)
|
||||
}
|
||||
}
|
||||
if opts.KeepLocalOnly {
|
||||
result.LocalOnly = append(result.LocalOnly, delta.Orphaned...)
|
||||
} else {
|
||||
result.Reverted = append(result.Reverted, delta.Orphaned...)
|
||||
}
|
||||
|
||||
filesToApply := make([]string, 0, len(delta.NeedsUpdate)+len(delta.NeedsApply))
|
||||
filesToApply = append(filesToApply, delta.NeedsUpdate...)
|
||||
filesToApply = append(filesToApply, delta.NeedsApply...)
|
||||
for _, path := range filesToApply {
|
||||
repoPatch, ok := repoPatchSet.Patches[path]
|
||||
if !ok || repoPatch.Op == patch.OpDeleted || repoPatch.Op == patch.OpBinary {
|
||||
continue
|
||||
}
|
||||
if err := applyRepoPatch(ctx, repoPatch, path, result); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
for _, path := range delta.Deleted {
|
||||
if err := deletePath(ctx, path); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result.Deleted = append(result.Deleted, path)
|
||||
}
|
||||
|
||||
result.Skipped = delta.UpToDate
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func applyRepoPatch(
|
||||
ctx *config.Context,
|
||||
repoPatch *patch.FilePatch,
|
||||
path string,
|
||||
result *patch.PullResult,
|
||||
) error {
|
||||
patchContent := repoPatch.Content
|
||||
patchFile := filepath.Join(ctx.PatchesDir, path)
|
||||
|
||||
if len(patchContent) == 0 {
|
||||
onDiskContent, err := os.ReadFile(patchFile)
|
||||
if err == nil {
|
||||
patchContent = onDiskContent
|
||||
}
|
||||
}
|
||||
if len(patchContent) == 0 {
|
||||
result.Skipped = append(result.Skipped, path)
|
||||
return nil
|
||||
}
|
||||
|
||||
if !git.FileExistsInCommit(ctx.ChromiumDir, ctx.BaseCommit, path) {
|
||||
_ = os.Remove(filepath.Join(ctx.ChromiumDir, path))
|
||||
}
|
||||
|
||||
conflict, err := git.Apply(ctx.ChromiumDir, patchContent, patchFile)
|
||||
if err != nil {
|
||||
return fmt.Errorf("pull: applying %s: %w", path, err)
|
||||
}
|
||||
|
||||
if conflict != nil {
|
||||
conflict.File = path
|
||||
conflict.RejectFile = path + ".rej"
|
||||
result.Conflicts = append(result.Conflicts, *conflict)
|
||||
} else {
|
||||
result.Applied = append(result.Applied, path)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func resetPathToBase(ctx *config.Context, chromiumPath string) error {
|
||||
if git.FileExistsInCommit(ctx.ChromiumDir, ctx.BaseCommit, chromiumPath) {
|
||||
return git.CheckoutFiles(ctx.ChromiumDir, ctx.BaseCommit, []string{chromiumPath})
|
||||
}
|
||||
|
||||
target := filepath.Join(ctx.ChromiumDir, chromiumPath)
|
||||
if err := os.Remove(target); err != nil && !os.IsNotExist(err) {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func deletePath(ctx *config.Context, chromiumPath string) error {
|
||||
target := filepath.Join(ctx.ChromiumDir, chromiumPath)
|
||||
if err := os.Remove(target); err != nil && !os.IsNotExist(err) {
|
||||
return fmt.Errorf("pull: deleting %s: %w", chromiumPath, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func filterDelta(d *patch.Delta, files []string) *patch.Delta {
|
||||
fileSet := make(map[string]bool)
|
||||
for _, f := range files {
|
||||
fileSet[f] = true
|
||||
}
|
||||
|
||||
filtered := &patch.Delta{}
|
||||
for _, f := range d.NeedsUpdate {
|
||||
if fileSet[f] {
|
||||
filtered.NeedsUpdate = append(filtered.NeedsUpdate, f)
|
||||
}
|
||||
}
|
||||
for _, f := range d.NeedsApply {
|
||||
if fileSet[f] {
|
||||
filtered.NeedsApply = append(filtered.NeedsApply, f)
|
||||
}
|
||||
}
|
||||
for _, f := range d.UpToDate {
|
||||
if fileSet[f] {
|
||||
filtered.UpToDate = append(filtered.UpToDate, f)
|
||||
}
|
||||
}
|
||||
for _, f := range d.Orphaned {
|
||||
if fileSet[f] {
|
||||
filtered.Orphaned = append(filtered.Orphaned, f)
|
||||
}
|
||||
}
|
||||
for _, f := range d.Deleted {
|
||||
if fileSet[f] {
|
||||
filtered.Deleted = append(filtered.Deleted, f)
|
||||
}
|
||||
}
|
||||
return filtered
|
||||
}
|
||||
|
||||
func sortPullResult(result *patch.PullResult) {
|
||||
sort.Strings(result.Applied)
|
||||
sort.Strings(result.Skipped)
|
||||
sort.Strings(result.Reverted)
|
||||
sort.Strings(result.LocalOnly)
|
||||
sort.Strings(result.Deleted)
|
||||
sort.Slice(result.Conflicts, func(i, j int) bool {
|
||||
return result.Conflicts[i].File < result.Conflicts[j].File
|
||||
})
|
||||
}
|
||||
@@ -1,156 +0,0 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"bdev/internal/config"
|
||||
)
|
||||
|
||||
func TestPullKeepsLocalOnlyFilesByDefault(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctx := setupPullFixture(t)
|
||||
result, err := Pull(ctx, PullOpts{KeepLocalOnly: true})
|
||||
if err != nil {
|
||||
t.Fatalf("Pull: %v", err)
|
||||
}
|
||||
|
||||
if !contains(result.Applied, "foo.txt") {
|
||||
t.Fatalf("expected foo.txt to be applied, got %#v", result.Applied)
|
||||
}
|
||||
if !contains(result.LocalOnly, "orphan.txt") {
|
||||
t.Fatalf("expected orphan.txt in local-only list, got %#v", result.LocalOnly)
|
||||
}
|
||||
if contains(result.Reverted, "orphan.txt") {
|
||||
t.Fatalf("orphan.txt should not be reverted when KeepLocalOnly=true")
|
||||
}
|
||||
|
||||
fooContent := mustRead(t, filepath.Join(ctx.ChromiumDir, "foo.txt"))
|
||||
if strings.TrimSpace(fooContent) != "repo-version" {
|
||||
t.Fatalf("unexpected foo.txt content: %q", fooContent)
|
||||
}
|
||||
|
||||
orphanContent := mustRead(t, filepath.Join(ctx.ChromiumDir, "orphan.txt"))
|
||||
if strings.TrimSpace(orphanContent) != "local-only-change" {
|
||||
t.Fatalf("orphan.txt should be preserved, got %q", orphanContent)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPullRevertsLocalOnlyWhenDisabled(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctx := setupPullFixture(t)
|
||||
result, err := Pull(ctx, PullOpts{KeepLocalOnly: false})
|
||||
if err != nil {
|
||||
t.Fatalf("Pull: %v", err)
|
||||
}
|
||||
|
||||
if !contains(result.Reverted, "orphan.txt") {
|
||||
t.Fatalf("expected orphan.txt to be reverted, got %#v", result.Reverted)
|
||||
}
|
||||
if contains(result.LocalOnly, "orphan.txt") {
|
||||
t.Fatalf("orphan.txt should not be local-only when KeepLocalOnly=false")
|
||||
}
|
||||
|
||||
orphanContent := mustRead(t, filepath.Join(ctx.ChromiumDir, "orphan.txt"))
|
||||
if strings.TrimSpace(orphanContent) != "orphan-base" {
|
||||
t.Fatalf("orphan.txt should be reset to base, got %q", orphanContent)
|
||||
}
|
||||
}
|
||||
|
||||
func setupPullFixture(t *testing.T) *config.Context {
|
||||
t.Helper()
|
||||
|
||||
root := t.TempDir()
|
||||
chromiumDir := filepath.Join(root, "chromium")
|
||||
patchesRepo := filepath.Join(root, "patches")
|
||||
|
||||
if err := os.MkdirAll(chromiumDir, 0o755); err != nil {
|
||||
t.Fatalf("mkdir chromium: %v", err)
|
||||
}
|
||||
if err := os.MkdirAll(patchesRepo, 0o755); err != nil {
|
||||
t.Fatalf("mkdir patches: %v", err)
|
||||
}
|
||||
|
||||
initRepo(t, chromiumDir)
|
||||
writeFile(t, filepath.Join(chromiumDir, "foo.txt"), "base\n")
|
||||
writeFile(t, filepath.Join(chromiumDir, "orphan.txt"), "orphan-base\n")
|
||||
runGit(t, chromiumDir, "add", "foo.txt", "orphan.txt")
|
||||
runGit(t, chromiumDir, "commit", "-m", "base")
|
||||
baseCommit := strings.TrimSpace(runGit(t, chromiumDir, "rev-parse", "HEAD"))
|
||||
|
||||
writeFile(t, filepath.Join(chromiumDir, "foo.txt"), "repo-version\n")
|
||||
patchDiff := runGit(t, chromiumDir, "diff", "--full-index", baseCommit, "--", "foo.txt")
|
||||
if strings.TrimSpace(patchDiff) == "" {
|
||||
t.Fatalf("expected patch diff for foo.txt")
|
||||
}
|
||||
runGit(t, chromiumDir, "checkout", baseCommit, "--", "foo.txt")
|
||||
|
||||
writeFile(t, filepath.Join(chromiumDir, "orphan.txt"), "local-only-change\n")
|
||||
|
||||
initRepo(t, patchesRepo)
|
||||
writeFile(t, filepath.Join(patchesRepo, "BASE_COMMIT"), baseCommit+"\n")
|
||||
writeFile(t, filepath.Join(patchesRepo, "chromium_patches", "foo.txt"), patchDiff)
|
||||
runGit(t, patchesRepo, "add", ".")
|
||||
runGit(t, patchesRepo, "commit", "-m", "seed patch repo")
|
||||
|
||||
return &config.Context{
|
||||
Config: &config.Config{Name: "test-checkout", PatchesRepo: patchesRepo},
|
||||
State: &config.State{},
|
||||
ChromiumDir: chromiumDir,
|
||||
BrosDir: filepath.Join(chromiumDir, ".bros"),
|
||||
PatchesRepo: patchesRepo,
|
||||
PatchesDir: filepath.Join(patchesRepo, "chromium_patches"),
|
||||
BaseCommit: baseCommit,
|
||||
}
|
||||
}
|
||||
|
||||
func initRepo(t *testing.T, dir string) {
|
||||
t.Helper()
|
||||
runGit(t, dir, "init")
|
||||
runGit(t, dir, "config", "user.email", "bdev-test@example.com")
|
||||
runGit(t, dir, "config", "user.name", "bdev test")
|
||||
}
|
||||
|
||||
func runGit(t *testing.T, dir string, args ...string) string {
|
||||
t.Helper()
|
||||
cmd := exec.Command("git", args...)
|
||||
cmd.Dir = dir
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
t.Fatalf("git %v failed: %v\n%s", args, err, string(out))
|
||||
}
|
||||
return string(out)
|
||||
}
|
||||
|
||||
func contains(items []string, target string) bool {
|
||||
for _, item := range items {
|
||||
if item == target {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func writeFile(t *testing.T, path, content string) {
|
||||
t.Helper()
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
t.Fatalf("mkdir %s: %v", path, err)
|
||||
}
|
||||
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
|
||||
t.Fatalf("write %s: %v", path, err)
|
||||
}
|
||||
}
|
||||
|
||||
func mustRead(t *testing.T, path string) string {
|
||||
t.Helper()
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("read %s: %v", path, err)
|
||||
}
|
||||
return string(data)
|
||||
}
|
||||
@@ -1,147 +0,0 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
|
||||
"bdev/internal/config"
|
||||
"bdev/internal/git"
|
||||
"bdev/internal/patch"
|
||||
)
|
||||
|
||||
type PushOpts struct {
|
||||
DryRun bool
|
||||
Files []string
|
||||
}
|
||||
|
||||
func Push(ctx *config.Context, opts PushOpts) (*patch.PushResult, error) {
|
||||
result := &patch.PushResult{}
|
||||
|
||||
// Phase 1: Discover changed files (working tree vs BASE)
|
||||
nameStatus, err := git.DiffNameStatus(ctx.ChromiumDir, ctx.BaseCommit)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("push: discovering changes: %w", err)
|
||||
}
|
||||
|
||||
untracked, err := git.UntrackedFiles(ctx.ChromiumDir)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("push: discovering untracked files: %w", err)
|
||||
}
|
||||
untrackedSet := make(map[string]bool, len(untracked))
|
||||
for _, path := range untracked {
|
||||
untrackedSet[path] = true
|
||||
if _, exists := nameStatus[path]; !exists {
|
||||
nameStatus[path] = patch.OpAdded
|
||||
}
|
||||
}
|
||||
|
||||
if len(nameStatus) == 0 {
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// Filter to requested files if specified
|
||||
if len(opts.Files) > 0 {
|
||||
filtered := make(map[string]patch.FileOp)
|
||||
for _, f := range opts.Files {
|
||||
if op, ok := nameStatus[f]; ok {
|
||||
filtered[f] = op
|
||||
}
|
||||
}
|
||||
nameStatus = filtered
|
||||
}
|
||||
|
||||
if len(nameStatus) == 0 {
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// Phase 2: Generate patches
|
||||
var diffOutput []byte
|
||||
files := make([]string, 0, len(nameStatus))
|
||||
for f := range nameStatus {
|
||||
files = append(files, f)
|
||||
}
|
||||
sort.Strings(files)
|
||||
|
||||
diffOutput, err = git.DiffFiles(ctx.ChromiumDir, ctx.BaseCommit, files)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("push: generating diffs: %w", err)
|
||||
}
|
||||
for _, file := range files {
|
||||
if !untrackedSet[file] {
|
||||
continue
|
||||
}
|
||||
noIndexDiff, err := git.DiffNoIndexFile(ctx.ChromiumDir, file)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("push: generating no-index diff for %s: %w", file, err)
|
||||
}
|
||||
if len(noIndexDiff) == 0 {
|
||||
continue
|
||||
}
|
||||
if len(diffOutput) > 0 && diffOutput[len(diffOutput)-1] != '\n' {
|
||||
diffOutput = append(diffOutput, '\n')
|
||||
}
|
||||
diffOutput = append(diffOutput, noIndexDiff...)
|
||||
}
|
||||
|
||||
patchSet, err := patch.ParseUnifiedDiff(diffOutput)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("push: parsing diffs: %w", err)
|
||||
}
|
||||
patchSet.Base = ctx.BaseCommit
|
||||
|
||||
// Merge in deleted files that won't appear in diff output
|
||||
for path, op := range nameStatus {
|
||||
if op == patch.OpDeleted {
|
||||
if _, exists := patchSet.Patches[path]; !exists {
|
||||
patchSet.Patches[path] = &patch.FilePatch{
|
||||
Path: path,
|
||||
Op: patch.OpDeleted,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Classify results for reporting
|
||||
existingPatches := make(map[string]bool)
|
||||
if existing, err := patch.ReadPatchFiles(ctx.PatchesDir); err == nil {
|
||||
for p := range existing {
|
||||
existingPatches[p] = true
|
||||
}
|
||||
}
|
||||
|
||||
for path, fp := range patchSet.Patches {
|
||||
switch fp.Op {
|
||||
case patch.OpDeleted:
|
||||
result.Deleted = append(result.Deleted, path)
|
||||
case patch.OpAdded:
|
||||
result.Added = append(result.Added, path)
|
||||
default:
|
||||
if existingPatches[path] {
|
||||
result.Modified = append(result.Modified, path)
|
||||
} else {
|
||||
result.Added = append(result.Added, path)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
sort.Strings(result.Modified)
|
||||
sort.Strings(result.Added)
|
||||
sort.Strings(result.Deleted)
|
||||
|
||||
// Phase 3: Write patches
|
||||
if err := patch.WritePatchSet(ctx.PatchesDir, patchSet, opts.DryRun); err != nil {
|
||||
return nil, fmt.Errorf("push: writing patches: %w", err)
|
||||
}
|
||||
|
||||
// Phase 4: Stale cleanup
|
||||
if !opts.DryRun && len(opts.Files) == 0 {
|
||||
stale, err := patch.RemoveStale(ctx.PatchesDir, patchSet, opts.DryRun)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("push: stale cleanup: %w", err)
|
||||
}
|
||||
result.Stale = stale
|
||||
sort.Strings(result.Stale)
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestPushIncludesUntrackedFiles(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctx := setupPullFixture(t)
|
||||
if err := resetPathToBase(ctx, "orphan.txt"); err != nil {
|
||||
t.Fatalf("reset orphan.txt: %v", err)
|
||||
}
|
||||
|
||||
writeFile(t, filepath.Join(ctx.ChromiumDir, "foo.txt"), "repo-version-v2\n")
|
||||
writeFile(t, filepath.Join(ctx.ChromiumDir, "new", "file.txt"), "brand-new\n")
|
||||
|
||||
dryRun, err := Push(ctx, PushOpts{DryRun: true})
|
||||
if err != nil {
|
||||
t.Fatalf("Push dry-run: %v", err)
|
||||
}
|
||||
if !contains(dryRun.Modified, "foo.txt") {
|
||||
t.Fatalf("expected foo.txt in modified set, got %#v", dryRun.Modified)
|
||||
}
|
||||
if !contains(dryRun.Added, "new/file.txt") {
|
||||
t.Fatalf("expected new/file.txt in added set, got %#v", dryRun.Added)
|
||||
}
|
||||
|
||||
result, err := Push(ctx, PushOpts{})
|
||||
if err != nil {
|
||||
t.Fatalf("Push: %v", err)
|
||||
}
|
||||
if !contains(result.Added, "new/file.txt") {
|
||||
t.Fatalf("expected new/file.txt in added result, got %#v", result.Added)
|
||||
}
|
||||
|
||||
patchContent := mustRead(t, filepath.Join(ctx.PatchesDir, "new", "file.txt"))
|
||||
if !strings.Contains(patchContent, "diff --git a/new/file.txt b/new/file.txt") {
|
||||
t.Fatalf("unexpected patch content for untracked file:\n%s", patchContent)
|
||||
}
|
||||
}
|
||||
@@ -1,62 +1,85 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"context"
|
||||
|
||||
"bdev/internal/config"
|
||||
"bdev/internal/git"
|
||||
"bdev/internal/patch"
|
||||
"github.com/browseros-ai/BrowserOS/packages/browseros/tools/bdev/internal/git"
|
||||
"github.com/browseros-ai/BrowserOS/packages/browseros/tools/bdev/internal/patch"
|
||||
"github.com/browseros-ai/BrowserOS/packages/browseros/tools/bdev/internal/repo"
|
||||
"github.com/browseros-ai/BrowserOS/packages/browseros/tools/bdev/internal/resolve"
|
||||
"github.com/browseros-ai/BrowserOS/packages/browseros/tools/bdev/internal/workspace"
|
||||
)
|
||||
|
||||
type StatusResult struct {
|
||||
CheckoutName string
|
||||
BaseCommit string
|
||||
ChromiumVersion string
|
||||
PatchesRepo string
|
||||
Ahead int
|
||||
Behind int
|
||||
Synced int
|
||||
AheadFiles []string
|
||||
BehindFiles []string
|
||||
SyncedFiles []string
|
||||
type WorkspaceStatus struct {
|
||||
Workspace workspace.Entry `json:"workspace"`
|
||||
RepoHead string `json:"repo_head"`
|
||||
BaseCommit string `json:"base_commit"`
|
||||
LastApplyRev string `json:"last_apply_rev,omitempty"`
|
||||
LastSyncRev string `json:"last_sync_rev,omitempty"`
|
||||
LastExtractRev string `json:"last_extract_rev,omitempty"`
|
||||
ActiveResolve bool `json:"active_resolve"`
|
||||
NeedsApply []string `json:"needs_apply"`
|
||||
NeedsUpdate []string `json:"needs_update"`
|
||||
Orphaned []string `json:"orphaned"`
|
||||
UpToDate []string `json:"up_to_date"`
|
||||
SyncState string `json:"sync_state"`
|
||||
}
|
||||
|
||||
func Status(ctx *config.Context, showFiles bool) (*StatusResult, error) {
|
||||
result := &StatusResult{
|
||||
CheckoutName: ctx.Config.Name,
|
||||
BaseCommit: ctx.BaseCommit,
|
||||
ChromiumVersion: ctx.ChromiumVersion,
|
||||
PatchesRepo: ctx.PatchesRepo,
|
||||
}
|
||||
|
||||
// Read repo patches
|
||||
repoPatchSet, err := patch.ReadPatchSet(ctx.PatchesDir)
|
||||
func InspectWorkspace(ctx context.Context, ws workspace.Entry, repoInfo *repo.Info) (*WorkspaceStatus, error) {
|
||||
head, err := git.HeadRev(ctx, repoInfo.Root)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("status: reading repo patches: %w", err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Read local state (working tree vs BASE)
|
||||
diffOutput, err := git.DiffFull(ctx.ChromiumDir, ctx.BaseCommit)
|
||||
state, err := workspace.LoadState(ws.Path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("status: reading local diffs: %w", err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
localPatchSet, err := patch.ParseUnifiedDiff(diffOutput)
|
||||
repoSet, err := patch.LoadRepoPatchSet(repoInfo.PatchesDir, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("status: parsing local diffs: %w", err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
delta := patch.Compare(localPatchSet, repoPatchSet)
|
||||
|
||||
result.Ahead = len(delta.Orphaned)
|
||||
result.Behind = len(delta.NeedsApply) + len(delta.NeedsUpdate)
|
||||
result.Synced = len(delta.UpToDate)
|
||||
|
||||
if showFiles {
|
||||
result.AheadFiles = delta.Orphaned
|
||||
result.BehindFiles = append(delta.NeedsApply, delta.NeedsUpdate...)
|
||||
result.SyncedFiles = delta.UpToDate
|
||||
localSet, err := patch.BuildWorkingTreePatchSet(ctx, ws.Path, repoInfo.BaseCommit, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
status := &WorkspaceStatus{
|
||||
Workspace: ws,
|
||||
RepoHead: head,
|
||||
BaseCommit: repoInfo.BaseCommit,
|
||||
LastApplyRev: state.LastApplyRev,
|
||||
LastSyncRev: state.LastSyncRev,
|
||||
LastExtractRev: state.LastExtractRev,
|
||||
ActiveResolve: resolve.Exists(ws.Path),
|
||||
}
|
||||
for _, delta := range patch.Compare(repoSet, localSet) {
|
||||
switch delta.Kind {
|
||||
case patch.NeedsApply:
|
||||
status.NeedsApply = append(status.NeedsApply, delta.Path)
|
||||
case patch.NeedsUpdate:
|
||||
status.NeedsUpdate = append(status.NeedsUpdate, delta.Path)
|
||||
case patch.Orphaned:
|
||||
status.Orphaned = append(status.Orphaned, delta.Path)
|
||||
case patch.UpToDate:
|
||||
status.UpToDate = append(status.UpToDate, delta.Path)
|
||||
}
|
||||
}
|
||||
status.SyncState = inferSyncState(status)
|
||||
return status, nil
|
||||
}
|
||||
|
||||
func inferSyncState(status *WorkspaceStatus) string {
|
||||
switch {
|
||||
case status.ActiveResolve:
|
||||
return "conflicted"
|
||||
case status.LastSyncRev == "":
|
||||
return "never-synced"
|
||||
case status.LastSyncRev != status.RepoHead:
|
||||
return "needs-sync"
|
||||
case len(status.NeedsApply) > 0:
|
||||
return "drifted"
|
||||
case len(status.NeedsUpdate) > 0 || len(status.Orphaned) > 0:
|
||||
return "local-changes"
|
||||
default:
|
||||
return "synced"
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
package engine
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestStatusReflectsAheadBehindAndSynced(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctx := setupPullFixture(t)
|
||||
|
||||
before, err := Status(ctx, true)
|
||||
if err != nil {
|
||||
t.Fatalf("Status before pull: %v", err)
|
||||
}
|
||||
if before.Ahead != 1 || before.Behind != 1 || before.Synced != 0 {
|
||||
t.Fatalf("unexpected status before pull: ahead=%d behind=%d synced=%d", before.Ahead, before.Behind, before.Synced)
|
||||
}
|
||||
if !contains(before.AheadFiles, "orphan.txt") {
|
||||
t.Fatalf("expected orphan.txt in ahead files, got %#v", before.AheadFiles)
|
||||
}
|
||||
if !contains(before.BehindFiles, "foo.txt") {
|
||||
t.Fatalf("expected foo.txt in behind files, got %#v", before.BehindFiles)
|
||||
}
|
||||
}
|
||||
130
packages/browseros/tools/bdev/internal/engine/sync.go
Normal file
130
packages/browseros/tools/bdev/internal/engine/sync.go
Normal file
@@ -0,0 +1,130 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/browseros-ai/BrowserOS/packages/browseros/tools/bdev/internal/git"
|
||||
"github.com/browseros-ai/BrowserOS/packages/browseros/tools/bdev/internal/repo"
|
||||
"github.com/browseros-ai/BrowserOS/packages/browseros/tools/bdev/internal/workspace"
|
||||
)
|
||||
|
||||
type SyncOptions struct {
|
||||
Workspace workspace.Entry
|
||||
Repo *repo.Info
|
||||
Remote string
|
||||
Rebase bool
|
||||
}
|
||||
|
||||
type SyncResult struct {
|
||||
Workspace string `json:"workspace"`
|
||||
Remote string `json:"remote"`
|
||||
RepoHead string `json:"repo_head"`
|
||||
StashRef string `json:"stash_ref,omitempty"`
|
||||
Rebased bool `json:"rebased"`
|
||||
Fallback bool `json:"fallback"`
|
||||
Applied []string `json:"applied,omitempty"`
|
||||
Conflicts []string `json:"conflicts,omitempty"`
|
||||
}
|
||||
|
||||
func Sync(ctx context.Context, opts SyncOptions) (*SyncResult, error) {
|
||||
if opts.Remote == "" {
|
||||
opts.Remote = "origin"
|
||||
}
|
||||
dirty, err := git.IsDirty(ctx, opts.Repo.Root)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if dirty {
|
||||
return nil, fmt.Errorf("patches repo has uncommitted changes; commit or stash them before syncing")
|
||||
}
|
||||
branch, err := git.CurrentBranch(ctx, opts.Repo.Root)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := git.PullRebase(ctx, opts.Repo.Root, opts.Remote, branch); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
head, err := git.HeadRev(ctx, opts.Repo.Root)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
state, err := workspace.LoadState(opts.Workspace.Path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result := &SyncResult{
|
||||
Workspace: opts.Workspace.Name,
|
||||
Remote: opts.Remote,
|
||||
RepoHead: head,
|
||||
Rebased: opts.Rebase,
|
||||
}
|
||||
status, err := InspectWorkspace(ctx, opts.Workspace, opts.Repo)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
divergent := append([]string{}, status.NeedsUpdate...)
|
||||
divergent = append(divergent, status.Orphaned...)
|
||||
if len(divergent) > 0 {
|
||||
stashRef, err := git.StashPush(ctx, opts.Workspace.Path, "bdev sync stash", true, divergent)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result.StashRef = stashRef
|
||||
state.PendingStash = stashRef
|
||||
if err := workspace.SaveState(opts.Workspace.Path, state); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if state.LastSyncRev == "" || state.BaseCommit != "" && state.BaseCommit != opts.Repo.BaseCommit {
|
||||
result.Fallback = true
|
||||
applyResult, err := Apply(ctx, ApplyOptions{
|
||||
Workspace: opts.Workspace,
|
||||
Repo: opts.Repo,
|
||||
Reset: true,
|
||||
Mode: "sync-reset",
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result.Applied = applyResult.Applied
|
||||
if len(applyResult.Conflicts) > 0 {
|
||||
for _, conflict := range applyResult.Conflicts {
|
||||
result.Conflicts = append(result.Conflicts, conflict.ChromiumPath)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
} else {
|
||||
applyResult, err := Apply(ctx, ApplyOptions{
|
||||
Workspace: opts.Workspace,
|
||||
Repo: opts.Repo,
|
||||
ChangedRef: state.LastSyncRev,
|
||||
RangeEnd: head,
|
||||
Mode: "sync",
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result.Applied = applyResult.Applied
|
||||
if len(applyResult.Conflicts) > 0 {
|
||||
for _, conflict := range applyResult.Conflicts {
|
||||
result.Conflicts = append(result.Conflicts, conflict.ChromiumPath)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
}
|
||||
if opts.Rebase && result.StashRef != "" {
|
||||
if err := git.StashPop(ctx, opts.Workspace.Path, result.StashRef); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
state.PendingStash = ""
|
||||
state.BaseCommit = opts.Repo.BaseCommit
|
||||
state.LastSyncRev = head
|
||||
state.LastSyncAt = time.Now().UTC()
|
||||
if err := workspace.SaveState(opts.Workspace.Path, state); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
@@ -1,123 +0,0 @@
|
||||
package git
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"bdev/internal/patch"
|
||||
)
|
||||
|
||||
var rejHunkRe = regexp.MustCompile(`Applying patch .* with (\d+) hunks?`)
|
||||
var rejFailRe = regexp.MustCompile(`(\d+) out of (\d+) hunks? FAILED`)
|
||||
|
||||
// Apply tries multiple strategies to apply a patch, falling back to --reject
|
||||
// only as a last resort. Mirrors the Python CLI fallback chain.
|
||||
func Apply(dir string, patchContent []byte, patchFile string) (*patch.ConflictInfo, error) {
|
||||
// Strategy 1: Clean apply
|
||||
if tryApply(dir, patchContent, "--ignore-whitespace", "--whitespace=nowarn", "-p1") == nil {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// Strategy 2: Three-way merge
|
||||
if tryApply(dir, patchContent, "--ignore-whitespace", "--whitespace=nowarn", "-p1", "--3way") == nil {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// Strategy 3: Whitespace fix
|
||||
if tryApply(dir, patchContent, "--ignore-whitespace", "--whitespace=fix", "-p1") == nil {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// Strategy 4: Reject (last resort — partially applies, creates .rej files)
|
||||
return applyReject(dir, patchContent, patchFile)
|
||||
}
|
||||
|
||||
// tryApply attempts a git apply with the given flags. Returns nil on success.
|
||||
func tryApply(dir string, patchContent []byte, flags ...string) error {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
|
||||
defer cancel()
|
||||
|
||||
args := append([]string{"apply"}, flags...)
|
||||
cmd := exec.CommandContext(ctx, "git", args...)
|
||||
cmd.Dir = dir
|
||||
cmd.Stdin = bytes.NewReader(patchContent)
|
||||
|
||||
var stderr bytes.Buffer
|
||||
cmd.Stderr = &stderr
|
||||
|
||||
return cmd.Run()
|
||||
}
|
||||
|
||||
// applyReject applies a patch with --reject, creating .rej files for failed hunks.
|
||||
func applyReject(dir string, patchContent []byte, patchFile string) (*patch.ConflictInfo, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
|
||||
defer cancel()
|
||||
|
||||
cmd := exec.CommandContext(ctx, "git", "apply",
|
||||
"--reject",
|
||||
"--ignore-whitespace",
|
||||
"--whitespace=nowarn",
|
||||
"-p1",
|
||||
)
|
||||
cmd.Dir = dir
|
||||
cmd.Stdin = bytes.NewReader(patchContent)
|
||||
|
||||
var stderr bytes.Buffer
|
||||
cmd.Stderr = &stderr
|
||||
|
||||
err := cmd.Run()
|
||||
if err == nil {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
stderrStr := stderr.String()
|
||||
info := &patch.ConflictInfo{
|
||||
PatchFile: patchFile,
|
||||
Error: strings.TrimSpace(stderrStr),
|
||||
}
|
||||
|
||||
if m := rejFailRe.FindStringSubmatch(stderrStr); len(m) == 3 {
|
||||
info.HunksFailed, _ = strconv.Atoi(m[1])
|
||||
info.HunksTotal, _ = strconv.Atoi(m[2])
|
||||
} else if m := rejHunkRe.FindStringSubmatch(stderrStr); len(m) == 2 {
|
||||
info.HunksTotal, _ = strconv.Atoi(m[1])
|
||||
info.HunksFailed = info.HunksTotal
|
||||
}
|
||||
|
||||
return info, nil
|
||||
}
|
||||
|
||||
// ApplyPatchFile applies a patch from a file path using the full fallback chain.
|
||||
func ApplyPatchFile(dir, patchPath string) (*patch.ConflictInfo, error) {
|
||||
content, err := os.ReadFile(patchPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("reading patch file %s: %w", patchPath, err)
|
||||
}
|
||||
info, err := Apply(dir, content, patchPath)
|
||||
if info != nil {
|
||||
info.PatchFile = patchPath
|
||||
}
|
||||
return info, err
|
||||
}
|
||||
|
||||
// ApplyCheck tests if a patch would apply without modifying anything.
|
||||
func ApplyCheck(dir string, patchContent []byte) error {
|
||||
cmd := exec.Command("git", "apply", "--check", "-p1")
|
||||
cmd.Dir = dir
|
||||
cmd.Stdin = bytes.NewReader(patchContent)
|
||||
|
||||
var stderr bytes.Buffer
|
||||
cmd.Stderr = &stderr
|
||||
|
||||
if err := cmd.Run(); err != nil {
|
||||
return fmt.Errorf("patch would not apply cleanly: %s", stderr.String())
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
package git
|
||||
|
||||
import "fmt"
|
||||
|
||||
// CheckoutFiles resets multiple files to a specific commit.
|
||||
// Batches into a single git call.
|
||||
func CheckoutFiles(dir, commit string, files []string) error {
|
||||
if len(files) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
args := []string{"checkout", commit, "--"}
|
||||
args = append(args, files...)
|
||||
|
||||
_, err := Run(dir, args...)
|
||||
if err != nil {
|
||||
return fmt.Errorf("checkout %s -- [%d files]: %w", commit, len(files), err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -1,179 +0,0 @@
|
||||
package git
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"bdev/internal/patch"
|
||||
)
|
||||
|
||||
// DiffNameStatus returns file paths mapped to their operation.
|
||||
// Diffs BASE against the working tree (not HEAD) so uncommitted patch
|
||||
// applications are visible.
|
||||
func DiffNameStatus(dir, base string) (map[string]patch.FileOp, error) {
|
||||
out, err := Run(dir, "diff", "--name-status", "-M", base)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("diff --name-status %s: %w", base, err)
|
||||
}
|
||||
|
||||
result := make(map[string]patch.FileOp)
|
||||
for _, line := range strings.Split(out, "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
fields := strings.Fields(line)
|
||||
if len(fields) < 2 {
|
||||
continue
|
||||
}
|
||||
|
||||
status := fields[0]
|
||||
path := fields[len(fields)-1]
|
||||
|
||||
switch {
|
||||
case status == "M":
|
||||
result[path] = patch.OpModified
|
||||
case status == "A":
|
||||
result[path] = patch.OpAdded
|
||||
case status == "D":
|
||||
result[path] = patch.OpDeleted
|
||||
case strings.HasPrefix(status, "R"):
|
||||
result[path] = patch.OpRenamed
|
||||
default:
|
||||
result[path] = patch.OpModified
|
||||
}
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// DiffFull returns the complete unified diff of BASE vs working tree.
|
||||
func DiffFull(dir, base string) ([]byte, error) {
|
||||
out, err := RunBytes(dir, "diff", "-M", "--full-index", base)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("diff %s: %w", base, err)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// DiffFile returns the working-tree diff for a single file.
|
||||
func DiffFile(dir, base, file string) ([]byte, error) {
|
||||
out, err := RunBytes(dir, "diff", "-M", "--full-index", base, "--", file)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("diff %s -- %s: %w", base, file, err)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// DiffFiles returns working-tree diffs for multiple files in one call.
|
||||
func DiffFiles(dir, base string, files []string) ([]byte, error) {
|
||||
args := []string{"diff", "-M", "--full-index", base, "--"}
|
||||
args = append(args, files...)
|
||||
out, err := RunBytes(dir, args...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("diff %s -- [%d files]: %w", base, len(files), err)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// DiffNoIndexFile builds a patch for an untracked file as if it were added from /dev/null.
|
||||
// git diff --no-index exits with status 1 when files differ; treat that as success.
|
||||
func DiffNoIndexFile(dir, file string) ([]byte, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second)
|
||||
defer cancel()
|
||||
|
||||
cmd := exec.CommandContext(ctx, "git", "diff", "--no-index", "--full-index", "/dev/null", file)
|
||||
cmd.Dir = dir
|
||||
|
||||
var stdout, stderr bytes.Buffer
|
||||
cmd.Stdout = &stdout
|
||||
cmd.Stderr = &stderr
|
||||
|
||||
err := cmd.Run()
|
||||
if err == nil {
|
||||
return stdout.Bytes(), nil
|
||||
}
|
||||
|
||||
if exitErr, ok := err.(*exec.ExitError); ok && exitErr.ExitCode() == 1 {
|
||||
return stdout.Bytes(), nil
|
||||
}
|
||||
|
||||
if ctx.Err() == context.DeadlineExceeded {
|
||||
return nil, fmt.Errorf("diff --no-index %s: timed out", file)
|
||||
}
|
||||
return nil, fmt.Errorf("diff --no-index %s: %w\n%s", file, err, stderr.String())
|
||||
}
|
||||
|
||||
// UntrackedFiles returns all untracked files (exclude-standard) in the repo.
|
||||
func UntrackedFiles(dir string) ([]string, error) {
|
||||
out, err := Run(dir, "ls-files", "--others", "--exclude-standard")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("ls-files --others: %w", err)
|
||||
}
|
||||
if strings.TrimSpace(out) == "" {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
var files []string
|
||||
for _, line := range strings.Split(out, "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if line != "" {
|
||||
files = append(files, line)
|
||||
}
|
||||
}
|
||||
sort.Strings(files)
|
||||
return files, nil
|
||||
}
|
||||
|
||||
// DiffChangedPathsBetween returns changed paths between two revisions.
|
||||
// It includes old and new paths for rename/copy records.
|
||||
func DiffChangedPathsBetween(dir, fromRev, toRev string, pathspec ...string) ([]string, error) {
|
||||
args := []string{
|
||||
"diff",
|
||||
"--name-status",
|
||||
"--find-renames",
|
||||
fmt.Sprintf("%s..%s", fromRev, toRev),
|
||||
}
|
||||
if len(pathspec) > 0 {
|
||||
args = append(args, "--")
|
||||
args = append(args, pathspec...)
|
||||
}
|
||||
|
||||
out, err := Run(dir, args...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("diff --name-status %s..%s: %w", fromRev, toRev, err)
|
||||
}
|
||||
|
||||
seen := make(map[string]bool)
|
||||
for _, line := range strings.Split(out, "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
fields := strings.Split(line, "\t")
|
||||
if len(fields) < 2 {
|
||||
continue
|
||||
}
|
||||
|
||||
for _, p := range fields[1:] {
|
||||
p = strings.TrimSpace(p)
|
||||
if p != "" {
|
||||
seen[p] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
paths := make([]string, 0, len(seen))
|
||||
for p := range seen {
|
||||
paths = append(paths, p)
|
||||
}
|
||||
sort.Strings(paths)
|
||||
return paths, nil
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
package git
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const defaultTimeout = 120 * time.Second
|
||||
|
||||
func Run(dir string, args ...string) (string, error) {
|
||||
out, err := RunBytes(dir, args...)
|
||||
return strings.TrimRight(string(out), "\n"), err
|
||||
}
|
||||
|
||||
func RunBytes(dir string, args ...string) ([]byte, error) {
|
||||
return RunWithTimeout(dir, defaultTimeout, args...)
|
||||
}
|
||||
|
||||
func RunWithTimeout(dir string, timeout time.Duration, args ...string) ([]byte, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
||||
defer cancel()
|
||||
|
||||
cmd := exec.CommandContext(ctx, "git", args...)
|
||||
cmd.Dir = dir
|
||||
|
||||
var stdout, stderr bytes.Buffer
|
||||
cmd.Stdout = &stdout
|
||||
cmd.Stderr = &stderr
|
||||
|
||||
if err := cmd.Run(); err != nil {
|
||||
if ctx.Err() == context.DeadlineExceeded {
|
||||
return nil, fmt.Errorf("git %s: timed out after %s", args[0], timeout)
|
||||
}
|
||||
return nil, fmt.Errorf("git %s: %w\n%s", args[0], err, stderr.String())
|
||||
}
|
||||
return stdout.Bytes(), nil
|
||||
}
|
||||
387
packages/browseros/tools/bdev/internal/git/git.go
Normal file
387
packages/browseros/tools/bdev/internal/git/git.go
Normal file
@@ -0,0 +1,387 @@
|
||||
package git
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type Result struct {
|
||||
Stdout string
|
||||
Stderr string
|
||||
Code int
|
||||
}
|
||||
|
||||
type FileChange struct {
|
||||
Status string `json:"status"`
|
||||
Path string `json:"path"`
|
||||
OldPath string `json:"old_path,omitempty"`
|
||||
}
|
||||
|
||||
func Run(ctx context.Context, dir string, stdin []byte, args ...string) (Result, error) {
|
||||
command := exec.CommandContext(ctx, "git", args...)
|
||||
command.Dir = dir
|
||||
if stdin != nil {
|
||||
command.Stdin = bytes.NewReader(stdin)
|
||||
}
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
command.Stdout = &stdout
|
||||
command.Stderr = &stderr
|
||||
err := command.Run()
|
||||
code := -1
|
||||
if command.ProcessState != nil {
|
||||
code = command.ProcessState.ExitCode()
|
||||
}
|
||||
result := Result{
|
||||
Stdout: stdout.String(),
|
||||
Stderr: stderr.String(),
|
||||
Code: code,
|
||||
}
|
||||
if err == nil {
|
||||
return result, nil
|
||||
}
|
||||
if errors.Is(err, context.DeadlineExceeded) || errors.Is(ctx.Err(), context.DeadlineExceeded) {
|
||||
return result, err
|
||||
}
|
||||
if errors.Is(err, context.Canceled) || errors.Is(ctx.Err(), context.Canceled) {
|
||||
return result, err
|
||||
}
|
||||
if command.ProcessState == nil {
|
||||
return result, err
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func HeadRev(ctx context.Context, dir string) (string, error) {
|
||||
result, err := Run(ctx, dir, nil, "rev-parse", "HEAD")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if result.Code != 0 {
|
||||
return "", errors.New(strings.TrimSpace(result.Stderr))
|
||||
}
|
||||
return strings.TrimSpace(result.Stdout), nil
|
||||
}
|
||||
|
||||
func CurrentBranch(ctx context.Context, dir string) (string, error) {
|
||||
result, err := Run(ctx, dir, nil, "branch", "--show-current")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if result.Code != 0 {
|
||||
return "", errors.New(strings.TrimSpace(result.Stderr))
|
||||
}
|
||||
return strings.TrimSpace(result.Stdout), nil
|
||||
}
|
||||
|
||||
func IsDirty(ctx context.Context, dir string) (bool, error) {
|
||||
return IsDirtyPaths(ctx, dir, nil)
|
||||
}
|
||||
|
||||
func IsDirtyPaths(ctx context.Context, dir string, pathspecs []string) (bool, error) {
|
||||
args := []string{"status", "--porcelain"}
|
||||
if len(pathspecs) > 0 {
|
||||
args = append(args, "--")
|
||||
args = append(args, pathspecs...)
|
||||
}
|
||||
result, err := Run(ctx, dir, nil, args...)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if result.Code != 0 {
|
||||
return false, errors.New(strings.TrimSpace(result.Stderr))
|
||||
}
|
||||
return strings.TrimSpace(result.Stdout) != "", nil
|
||||
}
|
||||
|
||||
func CommitExists(ctx context.Context, dir string, ref string) (bool, error) {
|
||||
result, err := Run(ctx, dir, nil, "rev-parse", "--verify", ref+"^{commit}")
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return result.Code == 0, nil
|
||||
}
|
||||
|
||||
func FileExistsAtCommit(ctx context.Context, dir string, ref string, rel string) (bool, error) {
|
||||
result, err := Run(ctx, dir, nil, "cat-file", "-e", fmt.Sprintf("%s:%s", ref, rel))
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return result.Code == 0, nil
|
||||
}
|
||||
|
||||
func ShowFile(ctx context.Context, dir string, ref string, rel string) ([]byte, error) {
|
||||
result, err := Run(ctx, dir, nil, "show", fmt.Sprintf("%s:%s", ref, rel))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if result.Code != 0 {
|
||||
return nil, errors.New(strings.TrimSpace(result.Stderr))
|
||||
}
|
||||
return []byte(result.Stdout), nil
|
||||
}
|
||||
|
||||
func CheckoutFiles(ctx context.Context, dir string, ref string, paths []string) error {
|
||||
if len(paths) == 0 {
|
||||
return nil
|
||||
}
|
||||
args := []string{"checkout", ref, "--"}
|
||||
args = append(args, paths...)
|
||||
result, err := Run(ctx, dir, nil, args...)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if result.Code != 0 {
|
||||
return errors.New(strings.TrimSpace(result.Stderr))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func ResetPathToCommit(ctx context.Context, dir string, ref string, rel string) error {
|
||||
exists, err := FileExistsAtCommit(ctx, dir, ref, rel)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
target := filepath.Join(dir, filepath.FromSlash(rel))
|
||||
if exists {
|
||||
return CheckoutFiles(ctx, dir, ref, []string{rel})
|
||||
}
|
||||
return os.RemoveAll(target)
|
||||
}
|
||||
|
||||
func DiffText(ctx context.Context, dir string, args ...string) (string, error) {
|
||||
result, err := Run(ctx, dir, nil, append([]string{"diff", "--binary", "-M"}, args...)...)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if result.Code != 0 {
|
||||
return "", errors.New(strings.TrimSpace(result.Stderr))
|
||||
}
|
||||
return result.Stdout, nil
|
||||
}
|
||||
|
||||
func DiffNoIndex(ctx context.Context, dir string, path string) (string, error) {
|
||||
result, err := Run(ctx, dir, nil, "diff", "--binary", "--no-index", "--", "/dev/null", path)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if result.Code != 0 && result.Code != 1 {
|
||||
return "", errors.New(strings.TrimSpace(result.Stderr))
|
||||
}
|
||||
return result.Stdout, nil
|
||||
}
|
||||
|
||||
func ListUntracked(ctx context.Context, dir string, pathspecs []string) ([]string, error) {
|
||||
args := []string{"ls-files", "--others", "--exclude-standard"}
|
||||
if len(pathspecs) > 0 {
|
||||
args = append(args, "--")
|
||||
args = append(args, pathspecs...)
|
||||
}
|
||||
result, err := Run(ctx, dir, nil, args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if result.Code != 0 {
|
||||
return nil, errors.New(strings.TrimSpace(result.Stderr))
|
||||
}
|
||||
lines := splitLines(result.Stdout)
|
||||
return lines, nil
|
||||
}
|
||||
|
||||
func DiffNameStatusBetween(ctx context.Context, dir string, from string, to string, pathspecs []string) ([]FileChange, error) {
|
||||
args := []string{"diff", "--name-status", "-M", fmt.Sprintf("%s..%s", from, to)}
|
||||
if len(pathspecs) > 0 {
|
||||
args = append(args, "--")
|
||||
args = append(args, pathspecs...)
|
||||
}
|
||||
return runNameStatus(ctx, dir, args...)
|
||||
}
|
||||
|
||||
func DiffTreeNameStatus(ctx context.Context, dir string, ref string, pathspecs []string) ([]FileChange, error) {
|
||||
args := []string{"diff-tree", "--no-commit-id", "--name-status", "-r", ref}
|
||||
if len(pathspecs) > 0 {
|
||||
args = append(args, "--")
|
||||
args = append(args, pathspecs...)
|
||||
}
|
||||
return runNameStatus(ctx, dir, args...)
|
||||
}
|
||||
|
||||
func RevListRange(ctx context.Context, dir string, start string, end string) ([]string, error) {
|
||||
result, err := Run(ctx, dir, nil, "rev-list", "--reverse", fmt.Sprintf("%s..%s", start, end))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if result.Code != 0 {
|
||||
return nil, errors.New(strings.TrimSpace(result.Stderr))
|
||||
}
|
||||
return splitLines(result.Stdout), nil
|
||||
}
|
||||
|
||||
func ApplyPatch(ctx context.Context, dir string, patch []byte) (string, error) {
|
||||
strategies := [][]string{
|
||||
{"apply", "--ignore-whitespace", "--whitespace=nowarn", "-p1"},
|
||||
{"apply", "--ignore-whitespace", "--whitespace=nowarn", "-p1", "--3way"},
|
||||
{"apply", "--ignore-whitespace", "--whitespace=fix", "-p1"},
|
||||
{"apply", "--reject", "--ignore-whitespace", "--whitespace=nowarn", "-p1"},
|
||||
}
|
||||
var lastErr string
|
||||
for _, args := range strategies {
|
||||
result, err := Run(ctx, dir, patch, args...)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if result.Code == 0 {
|
||||
return strings.Join(args[1:], " "), nil
|
||||
}
|
||||
lastErr = strings.TrimSpace(result.Stderr)
|
||||
}
|
||||
if lastErr == "" {
|
||||
lastErr = "git apply failed"
|
||||
}
|
||||
return "", errors.New(lastErr)
|
||||
}
|
||||
|
||||
func StashPush(ctx context.Context, dir string, message string, includeUntracked bool, paths []string) (string, error) {
|
||||
args := []string{"stash", "push", "-m", message}
|
||||
if includeUntracked {
|
||||
args = append(args, "-u")
|
||||
}
|
||||
if len(paths) > 0 {
|
||||
args = append(args, "--")
|
||||
args = append(args, paths...)
|
||||
}
|
||||
result, err := Run(ctx, dir, nil, args...)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if result.Code != 0 {
|
||||
return "", errors.New(strings.TrimSpace(result.Stderr))
|
||||
}
|
||||
if strings.Contains(result.Stdout, "No local changes to save") {
|
||||
return "", nil
|
||||
}
|
||||
list, err := Run(ctx, dir, nil, "stash", "list", "-1", "--format=%gd")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if list.Code != 0 {
|
||||
return "", errors.New(strings.TrimSpace(list.Stderr))
|
||||
}
|
||||
return strings.TrimSpace(list.Stdout), nil
|
||||
}
|
||||
|
||||
func StashPop(ctx context.Context, dir string, stashRef string) error {
|
||||
args := []string{"stash", "pop"}
|
||||
if stashRef != "" {
|
||||
args = append(args, stashRef)
|
||||
}
|
||||
result, err := Run(ctx, dir, nil, args...)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if result.Code != 0 {
|
||||
return errors.New(strings.TrimSpace(result.Stderr))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func PullRebase(ctx context.Context, dir string, remote string, branch string) error {
|
||||
args := []string{"pull", "--rebase"}
|
||||
if remote != "" {
|
||||
args = append(args, remote)
|
||||
if branch != "" {
|
||||
args = append(args, branch)
|
||||
}
|
||||
}
|
||||
result, err := Run(ctx, dir, nil, args...)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if result.Code != 0 {
|
||||
return errors.New(strings.TrimSpace(result.Stderr))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func AddPaths(ctx context.Context, dir string, paths []string) error {
|
||||
if len(paths) == 0 {
|
||||
return nil
|
||||
}
|
||||
args := append([]string{"add", "--"}, paths...)
|
||||
result, err := Run(ctx, dir, nil, args...)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if result.Code != 0 {
|
||||
return errors.New(strings.TrimSpace(result.Stderr))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func Commit(ctx context.Context, dir string, message string) error {
|
||||
result, err := Run(ctx, dir, nil, "commit", "-m", message)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if result.Code != 0 {
|
||||
return errors.New(strings.TrimSpace(result.Stderr))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func Push(ctx context.Context, dir string, remote string, branch string) error {
|
||||
args := []string{"push"}
|
||||
if remote != "" {
|
||||
args = append(args, remote)
|
||||
}
|
||||
if branch != "" {
|
||||
args = append(args, branch)
|
||||
}
|
||||
result, err := Run(ctx, dir, nil, args...)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if result.Code != 0 {
|
||||
return errors.New(strings.TrimSpace(result.Stderr))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func runNameStatus(ctx context.Context, dir string, args ...string) ([]FileChange, error) {
|
||||
result, err := Run(ctx, dir, nil, args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if result.Code != 0 {
|
||||
return nil, errors.New(strings.TrimSpace(result.Stderr))
|
||||
}
|
||||
var changes []FileChange
|
||||
for _, line := range splitLines(result.Stdout) {
|
||||
parts := strings.Split(line, "\t")
|
||||
if len(parts) < 2 {
|
||||
continue
|
||||
}
|
||||
change := FileChange{Status: parts[0][:1], Path: parts[len(parts)-1]}
|
||||
if change.Status == "R" || change.Status == "C" {
|
||||
if len(parts) >= 3 {
|
||||
change.OldPath = parts[1]
|
||||
}
|
||||
}
|
||||
changes = append(changes, change)
|
||||
}
|
||||
return changes, nil
|
||||
}
|
||||
|
||||
func splitLines(raw string) []string {
|
||||
lines := strings.Split(strings.TrimSpace(raw), "\n")
|
||||
if len(lines) == 1 && lines[0] == "" {
|
||||
return nil
|
||||
}
|
||||
return lines
|
||||
}
|
||||
28
packages/browseros/tools/bdev/internal/git/git_test.go
Normal file
28
packages/browseros/tools/bdev/internal/git/git_test.go
Normal file
@@ -0,0 +1,28 @@
|
||||
package git
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestRunReturnsContextError(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
t.Setenv("HOME", home)
|
||||
config := []byte("[alias]\n\thold = !sleep 5\n")
|
||||
if err := os.WriteFile(filepath.Join(home, ".gitconfig"), config, 0o644); err != nil {
|
||||
t.Fatalf("WriteFile: %v", err)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Millisecond)
|
||||
defer cancel()
|
||||
|
||||
if _, err := Run(ctx, t.TempDir(), nil, "hold"); err == nil {
|
||||
t.Fatalf("expected timeout error")
|
||||
}
|
||||
if ctx.Err() != context.DeadlineExceeded {
|
||||
t.Fatalf("expected context deadline exceeded, got %v", ctx.Err())
|
||||
}
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
package git
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ChangedFilesSince returns files changed between two revs in a subdirectory.
|
||||
func ChangedFilesSince(dir, fromRev, toRev, subdir string) ([]string, error) {
|
||||
args := []string{"diff", "--name-only", fromRev + ".." + toRev}
|
||||
if subdir != "" {
|
||||
args = append(args, "--", subdir)
|
||||
}
|
||||
|
||||
out, err := Run(dir, args...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("diff --name-only %s..%s: %w", fromRev, toRev, err)
|
||||
}
|
||||
|
||||
if out == "" {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
var files []string
|
||||
for _, line := range strings.Split(out, "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if line != "" {
|
||||
files = append(files, line)
|
||||
}
|
||||
}
|
||||
return files, nil
|
||||
}
|
||||
@@ -1,91 +0,0 @@
|
||||
package git
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func ListRemotes(dir string) ([]string, error) {
|
||||
out, err := Run(dir, "remote")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("listing remotes: %w", err)
|
||||
}
|
||||
if strings.TrimSpace(out) == "" {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
var remotes []string
|
||||
for _, line := range strings.Split(out, "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if line != "" {
|
||||
remotes = append(remotes, line)
|
||||
}
|
||||
}
|
||||
return remotes, nil
|
||||
}
|
||||
|
||||
func HasRemote(dir, remote string) (bool, error) {
|
||||
remotes, err := ListRemotes(dir)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
for _, name := range remotes {
|
||||
if name == remote {
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
|
||||
func CurrentBranch(dir string) (branch string, detached bool, err error) {
|
||||
out, runErr := Run(dir, "symbolic-ref", "--quiet", "--short", "HEAD")
|
||||
if runErr != nil {
|
||||
return "", true, nil
|
||||
}
|
||||
|
||||
branch = strings.TrimSpace(out)
|
||||
if branch == "" {
|
||||
return "", true, nil
|
||||
}
|
||||
return branch, false, nil
|
||||
}
|
||||
|
||||
func Fetch(dir, remote string) error {
|
||||
_, err := Run(dir, "fetch", "--prune", remote)
|
||||
if err != nil {
|
||||
return fmt.Errorf("fetch %s: %w", remote, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func Pull(dir, remote, branch string, rebase bool) error {
|
||||
args := []string{"pull"}
|
||||
if rebase {
|
||||
args = append(args, "--rebase")
|
||||
} else {
|
||||
args = append(args, "--ff-only")
|
||||
}
|
||||
args = append(args, remote)
|
||||
if strings.TrimSpace(branch) != "" {
|
||||
args = append(args, branch)
|
||||
}
|
||||
|
||||
_, err := Run(dir, args...)
|
||||
if err != nil {
|
||||
return fmt.Errorf("pull %s/%s: %w", remote, branch, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func Push(dir, remote, branch string) error {
|
||||
args := []string{"push", remote}
|
||||
if strings.TrimSpace(branch) != "" {
|
||||
args = append(args, "HEAD:"+branch)
|
||||
}
|
||||
|
||||
_, err := Run(dir, args...)
|
||||
if err != nil {
|
||||
return fmt.Errorf("push %s/%s: %w", remote, branch, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -1,101 +0,0 @@
|
||||
package git
|
||||
|
||||
import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestRemoteSyncAndStatusHelpers(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
root := t.TempDir()
|
||||
remote := filepath.Join(root, "remote.git")
|
||||
repoA := filepath.Join(root, "repo-a")
|
||||
repoB := filepath.Join(root, "repo-b")
|
||||
|
||||
runGit(t, root, "init", "--bare", remote)
|
||||
runGit(t, root, "clone", remote, repoA)
|
||||
configRepo(t, repoA)
|
||||
writeFile(t, filepath.Join(repoA, "README.md"), "one\n")
|
||||
runGit(t, repoA, "add", "README.md")
|
||||
runGit(t, repoA, "commit", "-m", "init")
|
||||
|
||||
branch := strings.TrimSpace(runGit(t, repoA, "symbolic-ref", "--short", "HEAD"))
|
||||
runGit(t, repoA, "push", "-u", "origin", branch)
|
||||
runGit(t, root, "clone", remote, repoB)
|
||||
configRepo(t, repoB)
|
||||
|
||||
remotes, err := ListRemotes(repoB)
|
||||
if err != nil {
|
||||
t.Fatalf("ListRemotes: %v", err)
|
||||
}
|
||||
if len(remotes) != 1 || remotes[0] != "origin" {
|
||||
t.Fatalf("unexpected remotes: %#v", remotes)
|
||||
}
|
||||
|
||||
writeFile(t, filepath.Join(repoA, "README.md"), "two\n")
|
||||
runGit(t, repoA, "commit", "-am", "update")
|
||||
runGit(t, repoA, "push", "origin", branch)
|
||||
targetRev := strings.TrimSpace(runGit(t, repoA, "rev-parse", "HEAD"))
|
||||
|
||||
if err := Fetch(repoB, "origin"); err != nil {
|
||||
t.Fatalf("Fetch: %v", err)
|
||||
}
|
||||
curBranch, detached, err := CurrentBranch(repoB)
|
||||
if err != nil {
|
||||
t.Fatalf("CurrentBranch: %v", err)
|
||||
}
|
||||
if detached {
|
||||
t.Fatalf("expected checked-out branch in clone")
|
||||
}
|
||||
if err := Pull(repoB, "origin", curBranch, true); err != nil {
|
||||
t.Fatalf("Pull: %v", err)
|
||||
}
|
||||
|
||||
currentRev, err := HeadRev(repoB)
|
||||
if err != nil {
|
||||
t.Fatalf("HeadRev: %v", err)
|
||||
}
|
||||
if currentRev != targetRev {
|
||||
t.Fatalf("repo-b did not fast-forward to latest rev: got %s want %s", currentRev, targetRev)
|
||||
}
|
||||
|
||||
writeFile(t, filepath.Join(repoB, "scratch.txt"), "dirty\n")
|
||||
dirty, err := IsDirty(repoB)
|
||||
if err != nil {
|
||||
t.Fatalf("IsDirty: %v", err)
|
||||
}
|
||||
if !dirty {
|
||||
t.Fatalf("expected repo to be dirty")
|
||||
}
|
||||
}
|
||||
|
||||
func configRepo(t *testing.T, dir string) {
|
||||
t.Helper()
|
||||
runGit(t, dir, "config", "user.email", "bdev-test@example.com")
|
||||
runGit(t, dir, "config", "user.name", "bdev test")
|
||||
}
|
||||
|
||||
func runGit(t *testing.T, dir string, args ...string) string {
|
||||
t.Helper()
|
||||
cmd := exec.Command("git", args...)
|
||||
cmd.Dir = dir
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
t.Fatalf("git %v failed: %v\n%s", args, err, string(out))
|
||||
}
|
||||
return string(out)
|
||||
}
|
||||
|
||||
func writeFile(t *testing.T, path, content string) {
|
||||
t.Helper()
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
t.Fatalf("mkdir: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
|
||||
t.Fatalf("write: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
package git
|
||||
|
||||
import "fmt"
|
||||
|
||||
func RevParse(dir, ref string) (string, error) {
|
||||
out, err := Run(dir, "rev-parse", ref)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("rev-parse %s: %w", ref, err)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func CommitExists(dir, commit string) bool {
|
||||
_, err := Run(dir, "cat-file", "-e", commit+"^{commit}")
|
||||
return err == nil
|
||||
}
|
||||
|
||||
func HeadRev(dir string) (string, error) {
|
||||
return RevParse(dir, "HEAD")
|
||||
}
|
||||
|
||||
// FileExistsInCommit checks whether a file path exists in a given commit.
|
||||
// Uses git cat-file -e {commit}:{path}.
|
||||
func FileExistsInCommit(dir, commit, filePath string) bool {
|
||||
_, err := Run(dir, "cat-file", "-e", commit+":"+filePath)
|
||||
return err == nil
|
||||
}
|
||||
@@ -1,61 +0,0 @@
|
||||
package git
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func StatusPorcelain(dir string, pathspec ...string) ([]string, error) {
|
||||
args := []string{"status", "--porcelain"}
|
||||
if len(pathspec) > 0 {
|
||||
args = append(args, "--")
|
||||
args = append(args, pathspec...)
|
||||
}
|
||||
|
||||
out, err := Run(dir, args...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("status --porcelain: %w", err)
|
||||
}
|
||||
|
||||
if strings.TrimSpace(out) == "" {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
var lines []string
|
||||
for _, line := range strings.Split(out, "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if line != "" {
|
||||
lines = append(lines, line)
|
||||
}
|
||||
}
|
||||
return lines, nil
|
||||
}
|
||||
|
||||
func IsDirty(dir string, pathspec ...string) (bool, error) {
|
||||
lines, err := StatusPorcelain(dir, pathspec...)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return len(lines) > 0, nil
|
||||
}
|
||||
|
||||
func Add(dir string, pathspec ...string) error {
|
||||
args := []string{"add", "-A"}
|
||||
if len(pathspec) > 0 {
|
||||
args = append(args, "--")
|
||||
args = append(args, pathspec...)
|
||||
}
|
||||
_, err := Run(dir, args...)
|
||||
if err != nil {
|
||||
return fmt.Errorf("add: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func Commit(dir, message string) error {
|
||||
_, err := Run(dir, "commit", "-m", message)
|
||||
if err != nil {
|
||||
return fmt.Errorf("commit: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -1,130 +0,0 @@
|
||||
package log
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"bdev/internal/patch"
|
||||
)
|
||||
|
||||
type Logger struct {
|
||||
logFile string
|
||||
}
|
||||
|
||||
func New(brosDir string) *Logger {
|
||||
return &Logger{
|
||||
logFile: filepath.Join(brosDir, "logs", "activity.log"),
|
||||
}
|
||||
}
|
||||
|
||||
func (l *Logger) LogPush(base string, result *patch.PushResult) error {
|
||||
var b strings.Builder
|
||||
|
||||
b.WriteString(divider('='))
|
||||
b.WriteString(fmt.Sprintf("PUSH %s\n", time.Now().Format("2006-01-02 15:04:05")))
|
||||
b.WriteString(fmt.Sprintf("Base: %s\n", base))
|
||||
b.WriteString(divider('-'))
|
||||
|
||||
for _, f := range result.Modified {
|
||||
b.WriteString(fmt.Sprintf(" M %s\n", f))
|
||||
}
|
||||
for _, f := range result.Added {
|
||||
b.WriteString(fmt.Sprintf(" A %s\n", f))
|
||||
}
|
||||
for _, f := range result.Deleted {
|
||||
b.WriteString(fmt.Sprintf(" D %s\n", f))
|
||||
}
|
||||
if len(result.Stale) > 0 {
|
||||
b.WriteString("Stale removed:\n")
|
||||
for _, f := range result.Stale {
|
||||
b.WriteString(fmt.Sprintf(" %s\n", f))
|
||||
}
|
||||
}
|
||||
|
||||
b.WriteString(fmt.Sprintf("Summary: %d pushed (%d modified, %d added, %d deleted)",
|
||||
result.Total(), len(result.Modified), len(result.Added), len(result.Deleted)))
|
||||
if len(result.Stale) > 0 {
|
||||
b.WriteString(fmt.Sprintf(", %d stale removed", len(result.Stale)))
|
||||
}
|
||||
b.WriteString("\n\n")
|
||||
|
||||
return l.append(b.String())
|
||||
}
|
||||
|
||||
func (l *Logger) LogPull(base, repoRev string, result *patch.PullResult) error {
|
||||
var b strings.Builder
|
||||
|
||||
b.WriteString(divider('='))
|
||||
b.WriteString(fmt.Sprintf("PULL %s\n", time.Now().Format("2006-01-02 15:04:05")))
|
||||
b.WriteString(fmt.Sprintf("Base: %s\n", base))
|
||||
b.WriteString(fmt.Sprintf("Patches repo rev: %s\n", repoRev))
|
||||
b.WriteString(divider('-'))
|
||||
|
||||
for _, f := range result.Applied {
|
||||
b.WriteString(fmt.Sprintf(" + %s\n", f))
|
||||
}
|
||||
for _, f := range result.Deleted {
|
||||
b.WriteString(fmt.Sprintf(" D %s\n", f))
|
||||
}
|
||||
for _, f := range result.Reverted {
|
||||
b.WriteString(fmt.Sprintf(" R %s (reverted to base)\n", f))
|
||||
}
|
||||
for _, f := range result.LocalOnly {
|
||||
b.WriteString(fmt.Sprintf(" ~ %s (local-only, kept)\n", f))
|
||||
}
|
||||
for _, c := range result.Conflicts {
|
||||
b.WriteString(fmt.Sprintf(" x %s -> %s (hunk %d/%d failed)\n",
|
||||
c.File, c.RejectFile, c.HunksFailed, c.HunksTotal))
|
||||
}
|
||||
if len(result.Skipped) > 0 {
|
||||
b.WriteString(fmt.Sprintf(" ~ %d files skipped (already up to date)\n", len(result.Skipped)))
|
||||
}
|
||||
|
||||
b.WriteString(fmt.Sprintf("Summary: %d applied, %d deleted, %d reverted, %d local-only, %d conflicts, %d skipped\n\n",
|
||||
len(result.Applied), len(result.Deleted), len(result.Reverted), len(result.LocalOnly), len(result.Conflicts), len(result.Skipped)))
|
||||
|
||||
return l.append(b.String())
|
||||
}
|
||||
|
||||
func (l *Logger) LogClone(base string, result *patch.PullResult) error {
|
||||
var b strings.Builder
|
||||
|
||||
b.WriteString(divider('='))
|
||||
b.WriteString(fmt.Sprintf("CLONE %s\n", time.Now().Format("2006-01-02 15:04:05")))
|
||||
b.WriteString(fmt.Sprintf("Base: %s\n", base))
|
||||
b.WriteString(divider('-'))
|
||||
|
||||
for _, f := range result.Applied {
|
||||
b.WriteString(fmt.Sprintf(" + %s\n", f))
|
||||
}
|
||||
for _, c := range result.Conflicts {
|
||||
b.WriteString(fmt.Sprintf(" x %s -> %s\n", c.File, c.RejectFile))
|
||||
}
|
||||
|
||||
b.WriteString(fmt.Sprintf("Summary: %d applied, %d conflicts\n\n",
|
||||
len(result.Applied), len(result.Conflicts)))
|
||||
|
||||
return l.append(b.String())
|
||||
}
|
||||
|
||||
func (l *Logger) append(entry string) error {
|
||||
if err := os.MkdirAll(filepath.Dir(l.logFile), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
f, err := os.OpenFile(l.logFile, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
_, err = f.WriteString(entry)
|
||||
return err
|
||||
}
|
||||
|
||||
func divider(ch byte) string {
|
||||
return strings.Repeat(string(ch), 50) + "\n"
|
||||
}
|
||||
@@ -1,66 +1,65 @@
|
||||
package patch
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"slices"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type Delta struct {
|
||||
NeedsUpdate []string // In both, but content differs
|
||||
NeedsApply []string // In repo only
|
||||
UpToDate []string // In both, content matches
|
||||
Orphaned []string // Local only, no repo patch
|
||||
Deleted []string // .deleted markers in repo
|
||||
}
|
||||
|
||||
// Compare computes the delta between local patch set and repo patch set.
|
||||
func Compare(local, repo *PatchSet) *Delta {
|
||||
d := &Delta{}
|
||||
|
||||
for path, repoPatch := range repo.Patches {
|
||||
if repoPatch.Op == OpDeleted {
|
||||
d.Deleted = append(d.Deleted, path)
|
||||
continue
|
||||
}
|
||||
|
||||
localPatch, exists := local.Patches[path]
|
||||
if !exists {
|
||||
d.NeedsApply = append(d.NeedsApply, path)
|
||||
continue
|
||||
}
|
||||
|
||||
if patchContentEqual(localPatch.Content, repoPatch.Content) {
|
||||
d.UpToDate = append(d.UpToDate, path)
|
||||
} else {
|
||||
d.NeedsUpdate = append(d.NeedsUpdate, path)
|
||||
func Compare(repo PatchSet, local PatchSet) []Delta {
|
||||
seen := map[string]bool{}
|
||||
var deltas []Delta
|
||||
for rel, repoPatch := range repo {
|
||||
seen[rel] = true
|
||||
localPatch, ok := local[rel]
|
||||
switch {
|
||||
case !ok:
|
||||
deltas = append(deltas, Delta{Path: rel, Kind: NeedsApply, Repo: ptr(repoPatch)})
|
||||
case signature(repoPatch) == signature(localPatch):
|
||||
deltas = append(deltas, Delta{Path: rel, Kind: UpToDate, Repo: ptr(repoPatch), Local: ptr(localPatch)})
|
||||
default:
|
||||
deltas = append(deltas, Delta{Path: rel, Kind: NeedsUpdate, Repo: ptr(repoPatch), Local: ptr(localPatch)})
|
||||
}
|
||||
}
|
||||
|
||||
for path := range local.Patches {
|
||||
if _, exists := repo.Patches[path]; !exists {
|
||||
d.Orphaned = append(d.Orphaned, path)
|
||||
}
|
||||
}
|
||||
|
||||
return d
|
||||
}
|
||||
|
||||
func patchContentEqual(a, b []byte) bool {
|
||||
return bytes.Equal(
|
||||
normalizePatch(a),
|
||||
normalizePatch(b),
|
||||
)
|
||||
}
|
||||
|
||||
func normalizePatch(content []byte) []byte {
|
||||
lines := strings.Split(string(content), "\n")
|
||||
var normalized []string
|
||||
for _, line := range lines {
|
||||
// Skip index lines (they contain hashes that may differ)
|
||||
if strings.HasPrefix(line, "index ") {
|
||||
for rel, localPatch := range local {
|
||||
if seen[rel] {
|
||||
continue
|
||||
}
|
||||
normalized = append(normalized, strings.TrimRight(line, " \t"))
|
||||
deltas = append(deltas, Delta{Path: rel, Kind: Orphaned, Local: ptr(localPatch)})
|
||||
}
|
||||
return []byte(strings.Join(normalized, "\n"))
|
||||
slices.SortFunc(deltas, func(a, b Delta) int {
|
||||
if a.Path < b.Path {
|
||||
return -1
|
||||
}
|
||||
if a.Path > b.Path {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
})
|
||||
return deltas
|
||||
}
|
||||
|
||||
func signature(p FilePatch) string {
|
||||
switch {
|
||||
case p.Op == OpDelete:
|
||||
return "delete:" + NormalizeChromiumPath(p.Path)
|
||||
case p.IsPureRename():
|
||||
return strings.Join([]string{"rename", NormalizeChromiumPath(p.OldPath), NormalizeChromiumPath(p.Path)}, ":")
|
||||
case p.Op == OpBinary && len(p.Content) == 0:
|
||||
return "binary:" + NormalizeChromiumPath(p.Path)
|
||||
default:
|
||||
lines := strings.Split(strings.ReplaceAll(string(p.Content), "\r\n", "\n"), "\n")
|
||||
normalized := make([]string, 0, len(lines))
|
||||
for _, line := range lines {
|
||||
if strings.HasPrefix(line, "index ") {
|
||||
continue
|
||||
}
|
||||
normalized = append(normalized, strings.TrimRight(line, " \t"))
|
||||
}
|
||||
return strings.Join(append([]string{string(p.Op), NormalizeChromiumPath(p.Path), NormalizeChromiumPath(p.OldPath)}, normalized...), "\n")
|
||||
}
|
||||
}
|
||||
|
||||
func ptr(p FilePatch) *FilePatch {
|
||||
copy := p
|
||||
return ©
|
||||
}
|
||||
|
||||
@@ -1,105 +1,79 @@
|
||||
package patch
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var diffHeaderPrefix = []byte("diff --git ")
|
||||
var diffHeader = regexp.MustCompile(`^diff --git a/(.*) b/(.*)$`)
|
||||
|
||||
// ParseUnifiedDiff parses a full `git diff` output into individual file patches.
|
||||
func ParseUnifiedDiff(raw []byte) (*PatchSet, error) {
|
||||
ps := NewPatchSet("")
|
||||
func ParseDiffOutput(diff string) (PatchSet, error) {
|
||||
patches := PatchSet{}
|
||||
lines := strings.SplitAfter(diff, "\n")
|
||||
var current *FilePatch
|
||||
var buffer strings.Builder
|
||||
|
||||
chunks := splitDiffChunks(raw)
|
||||
for _, chunk := range chunks {
|
||||
fp, err := parseSingleDiff(chunk)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parsing diff chunk: %w", err)
|
||||
flush := func() error {
|
||||
if current == nil {
|
||||
return nil
|
||||
}
|
||||
if fp != nil {
|
||||
ps.Patches[fp.Path] = fp
|
||||
current.Content = []byte(buffer.String())
|
||||
if current.Path == "" {
|
||||
return fmt.Errorf("diff patch missing target path")
|
||||
}
|
||||
patches[current.Path] = *current
|
||||
current = nil
|
||||
buffer.Reset()
|
||||
return nil
|
||||
}
|
||||
|
||||
return ps, nil
|
||||
}
|
||||
|
||||
func splitDiffChunks(raw []byte) [][]byte {
|
||||
var chunks [][]byte
|
||||
lines := bytes.Split(raw, []byte("\n"))
|
||||
|
||||
var current [][]byte
|
||||
for _, line := range lines {
|
||||
if bytes.HasPrefix(line, diffHeaderPrefix) {
|
||||
if len(current) > 0 {
|
||||
chunks = append(chunks, bytes.Join(current, []byte("\n")))
|
||||
if strings.HasPrefix(line, "diff --git ") {
|
||||
if err := flush(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
match := diffHeader.FindStringSubmatch(strings.TrimRight(line, "\n"))
|
||||
if len(match) != 3 {
|
||||
return nil, fmt.Errorf("invalid diff header: %s", strings.TrimSpace(line))
|
||||
}
|
||||
current = &FilePatch{
|
||||
Path: NormalizeChromiumPath(match[2]),
|
||||
Op: OpModify,
|
||||
}
|
||||
current = [][]byte{line}
|
||||
} else if len(current) > 0 {
|
||||
current = append(current, line)
|
||||
}
|
||||
}
|
||||
if len(current) > 0 {
|
||||
chunks = append(chunks, bytes.Join(current, []byte("\n")))
|
||||
}
|
||||
|
||||
return chunks
|
||||
}
|
||||
|
||||
func parseSingleDiff(chunk []byte) (*FilePatch, error) {
|
||||
lines := strings.Split(string(chunk), "\n")
|
||||
if len(lines) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
fp := &FilePatch{
|
||||
Op: OpModified,
|
||||
Content: chunk,
|
||||
}
|
||||
|
||||
// Parse the diff --git a/... b/... header
|
||||
header := lines[0]
|
||||
if !strings.HasPrefix(header, "diff --git ") {
|
||||
return nil, fmt.Errorf("unexpected header: %s", header)
|
||||
}
|
||||
|
||||
// Extract b/ path from the header
|
||||
parts := strings.SplitN(header, " b/", 2)
|
||||
if len(parts) == 2 {
|
||||
fp.Path = parts[1]
|
||||
}
|
||||
|
||||
// Scan header lines for operation type
|
||||
for _, line := range lines[1:] {
|
||||
if strings.HasPrefix(line, "diff --git ") || strings.HasPrefix(line, "@@") {
|
||||
break
|
||||
if current == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
buffer.WriteString(line)
|
||||
trimmed := strings.TrimRight(line, "\n")
|
||||
switch {
|
||||
case strings.HasPrefix(line, "new file mode"):
|
||||
fp.Op = OpAdded
|
||||
case strings.HasPrefix(line, "deleted file mode"):
|
||||
fp.Op = OpDeleted
|
||||
case strings.HasPrefix(line, "rename from "):
|
||||
fp.Op = OpRenamed
|
||||
fp.OldPath = strings.TrimPrefix(line, "rename from ")
|
||||
case strings.HasPrefix(line, "rename to "):
|
||||
fp.Path = strings.TrimPrefix(line, "rename to ")
|
||||
case strings.HasPrefix(line, "similarity index "):
|
||||
s := strings.TrimPrefix(line, "similarity index ")
|
||||
s = strings.TrimSuffix(s, "%")
|
||||
fmt.Sscanf(s, "%d", &fp.Similarity)
|
||||
case strings.Contains(line, "Binary files"):
|
||||
fp.Op = OpBinary
|
||||
fp.IsBinary = true
|
||||
case strings.HasPrefix(trimmed, "new file mode "):
|
||||
current.Op = OpAdd
|
||||
case strings.HasPrefix(trimmed, "deleted file mode "):
|
||||
current.Op = OpDelete
|
||||
case strings.HasPrefix(trimmed, "rename from "):
|
||||
current.Op = OpRename
|
||||
current.OldPath = NormalizeChromiumPath(strings.TrimPrefix(trimmed, "rename from "))
|
||||
case strings.HasPrefix(trimmed, "copy from "):
|
||||
current.Op = OpCopy
|
||||
current.OldPath = NormalizeChromiumPath(strings.TrimPrefix(trimmed, "copy from "))
|
||||
case strings.HasPrefix(trimmed, "similarity index "):
|
||||
value := strings.TrimSuffix(strings.TrimPrefix(trimmed, "similarity index "), "%")
|
||||
if similarity, err := strconv.Atoi(value); err == nil {
|
||||
current.Similarity = similarity
|
||||
}
|
||||
case strings.HasPrefix(trimmed, "Binary files "):
|
||||
current.IsBinary = true
|
||||
current.Op = OpBinary
|
||||
case trimmed == "GIT binary patch":
|
||||
current.IsBinary = true
|
||||
}
|
||||
}
|
||||
|
||||
if fp.Path == "" {
|
||||
return nil, nil
|
||||
if err := flush(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return fp, nil
|
||||
return patches, nil
|
||||
}
|
||||
|
||||
146
packages/browseros/tools/bdev/internal/patch/patch_test.go
Normal file
146
packages/browseros/tools/bdev/internal/patch/patch_test.go
Normal file
@@ -0,0 +1,146 @@
|
||||
package patch
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestParseDiffOutputDetectsRenameAndDeleteSignatures(t *testing.T) {
|
||||
renameDiff := `diff --git a/chrome/old.cc b/chrome/new.cc
|
||||
similarity index 100%
|
||||
rename from chrome/old.cc
|
||||
rename to chrome/new.cc
|
||||
`
|
||||
deleteDiff := `diff --git a/chrome/dead.cc b/chrome/dead.cc
|
||||
deleted file mode 100644
|
||||
index 123..000 100644
|
||||
--- a/chrome/dead.cc
|
||||
+++ /dev/null
|
||||
@@ -1 +0,0 @@
|
||||
-gone
|
||||
`
|
||||
renameSet, err := ParseDiffOutput(renameDiff)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseDiffOutput rename: %v", err)
|
||||
}
|
||||
deleteSet, err := ParseDiffOutput(deleteDiff)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseDiffOutput delete: %v", err)
|
||||
}
|
||||
renamePatch := renameSet["chrome/new.cc"]
|
||||
if !renamePatch.IsPureRename() {
|
||||
t.Fatalf("expected pure rename patch")
|
||||
}
|
||||
if deletePatch := deleteSet["chrome/dead.cc"]; signature(deletePatch) != "delete:chrome/dead.cc" {
|
||||
t.Fatalf("unexpected delete signature: %s", signature(deletePatch))
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteRepoPatchSetWritesMarkersAndReloads(t *testing.T) {
|
||||
patchesDir := t.TempDir()
|
||||
set := PatchSet{
|
||||
"chrome/dead.cc": {
|
||||
Path: "chrome/dead.cc",
|
||||
Op: OpDelete,
|
||||
},
|
||||
"chrome/new.cc": {
|
||||
Path: "chrome/new.cc",
|
||||
Op: OpRename,
|
||||
OldPath: "chrome/old.cc",
|
||||
Similarity: 100,
|
||||
Content: []byte(`diff --git a/chrome/old.cc b/chrome/new.cc
|
||||
similarity index 100%
|
||||
rename from chrome/old.cc
|
||||
rename to chrome/new.cc
|
||||
`),
|
||||
},
|
||||
}
|
||||
if _, _, err := WriteRepoPatchSet(patchesDir, set, nil); err != nil {
|
||||
t.Fatalf("WriteRepoPatchSet: %v", err)
|
||||
}
|
||||
if _, err := filepath.Abs(filepath.Join(patchesDir, "chrome", "dead.cc.deleted")); err != nil {
|
||||
t.Fatalf("abs: %v", err)
|
||||
}
|
||||
loaded, err := LoadRepoPatchSet(patchesDir, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadRepoPatchSet: %v", err)
|
||||
}
|
||||
if loaded["chrome/dead.cc"].Op != OpDelete {
|
||||
t.Fatalf("expected delete marker to round-trip")
|
||||
}
|
||||
if !loaded["chrome/new.cc"].IsPureRename() {
|
||||
t.Fatalf("expected rename marker to round-trip")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPathMatchesSkipsInternalState(t *testing.T) {
|
||||
if PathMatches(".bdev/state.yaml", nil) {
|
||||
t.Fatalf("expected internal state path to be ignored")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildRangePatchSetUsesLatestBaseScopedPatch(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
repoDir := t.TempDir()
|
||||
runGit(t, repoDir, "init")
|
||||
runGit(t, repoDir, "config", "user.name", "Test User")
|
||||
runGit(t, repoDir, "config", "user.email", "test@example.com")
|
||||
|
||||
writeRepoFile(t, filepath.Join(repoDir, "chrome", "foo.txt"), "one\n")
|
||||
runGit(t, repoDir, "add", "chrome/foo.txt")
|
||||
runGit(t, repoDir, "commit", "-m", "base")
|
||||
base := gitOutput(t, repoDir, "rev-parse", "HEAD")
|
||||
|
||||
writeRepoFile(t, filepath.Join(repoDir, "chrome", "foo.txt"), "two\n")
|
||||
runGit(t, repoDir, "commit", "-am", "step one")
|
||||
writeRepoFile(t, filepath.Join(repoDir, "chrome", "foo.txt"), "three\n")
|
||||
runGit(t, repoDir, "commit", "-am", "step two")
|
||||
end := gitOutput(t, repoDir, "rev-parse", "HEAD")
|
||||
|
||||
set, err := BuildRangePatchSet(ctx, repoDir, base, end, base, false, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("BuildRangePatchSet: %v", err)
|
||||
}
|
||||
content := string(set["chrome/foo.txt"].Content)
|
||||
if !strings.Contains(content, "+three") {
|
||||
t.Fatalf("expected final patch content, got %q", content)
|
||||
}
|
||||
if strings.Contains(content, "+two") {
|
||||
t.Fatalf("expected latest base-scoped patch, got %q", content)
|
||||
}
|
||||
}
|
||||
|
||||
func runGit(t *testing.T, dir string, args ...string) {
|
||||
t.Helper()
|
||||
cmd := exec.Command("git", args...)
|
||||
cmd.Dir = dir
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
t.Fatalf("git %s: %v\n%s", strings.Join(args, " "), err, string(output))
|
||||
}
|
||||
}
|
||||
|
||||
func gitOutput(t *testing.T, dir string, args ...string) string {
|
||||
t.Helper()
|
||||
cmd := exec.Command("git", args...)
|
||||
cmd.Dir = dir
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
t.Fatalf("git %s: %v\n%s", strings.Join(args, " "), err, string(output))
|
||||
}
|
||||
return strings.TrimSpace(string(output))
|
||||
}
|
||||
|
||||
func writeRepoFile(t *testing.T, path string, body string) {
|
||||
t.Helper()
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
t.Fatalf("mkdir: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(path, []byte(body), 0o644); err != nil {
|
||||
t.Fatalf("write: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -1,159 +0,0 @@
|
||||
package patch
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"golang.org/x/sync/errgroup"
|
||||
)
|
||||
|
||||
// ReadPatchSet reads all patches from the chromium_patches/ directory.
|
||||
func ReadPatchSet(patchesDir string) (*PatchSet, error) {
|
||||
ps := NewPatchSet("")
|
||||
|
||||
// Collect file paths
|
||||
var filePaths []string
|
||||
err := filepath.Walk(patchesDir, func(path string, info os.FileInfo, err error) error {
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
if !info.IsDir() {
|
||||
filePaths = append(filePaths, path)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
g, _ := errgroup.WithContext(context.Background())
|
||||
g.SetLimit(runtime.NumCPU())
|
||||
|
||||
var mu sync.Mutex
|
||||
|
||||
for _, path := range filePaths {
|
||||
path := path
|
||||
g.Go(func() error {
|
||||
content, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
rel, err := filepath.Rel(patchesDir, path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fp := classifyPatchFile(rel, content)
|
||||
mu.Lock()
|
||||
if existing, ok := ps.Patches[fp.Path]; ok {
|
||||
ps.Patches[fp.Path] = mergePatchEntry(existing, fp)
|
||||
} else {
|
||||
ps.Patches[fp.Path] = fp
|
||||
}
|
||||
mu.Unlock()
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
if err := g.Wait(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return ps, nil
|
||||
}
|
||||
|
||||
// ReadPatchFiles returns a map of chromium paths to true for all patches in the directory.
|
||||
// Lighter than ReadPatchSet — only collects paths, not content.
|
||||
func ReadPatchFiles(patchesDir string) (map[string]bool, error) {
|
||||
result := make(map[string]bool)
|
||||
|
||||
err := filepath.Walk(patchesDir, func(path string, info os.FileInfo, err error) error {
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
if info.IsDir() {
|
||||
return nil
|
||||
}
|
||||
|
||||
rel, err := filepath.Rel(patchesDir, path)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
chromPath := rel
|
||||
chromPath = strings.TrimSuffix(chromPath, ".deleted")
|
||||
chromPath = strings.TrimSuffix(chromPath, ".binary")
|
||||
chromPath = strings.TrimSuffix(chromPath, ".rename")
|
||||
|
||||
result[chromPath] = true
|
||||
return nil
|
||||
})
|
||||
|
||||
return result, err
|
||||
}
|
||||
|
||||
func classifyPatchFile(rel string, content []byte) *FilePatch {
|
||||
fp := &FilePatch{
|
||||
Path: rel,
|
||||
Content: content,
|
||||
Op: OpModified,
|
||||
}
|
||||
|
||||
switch {
|
||||
case strings.HasSuffix(rel, ".deleted"):
|
||||
fp.Path = strings.TrimSuffix(rel, ".deleted")
|
||||
fp.Op = OpDeleted
|
||||
fp.Content = nil
|
||||
case strings.HasSuffix(rel, ".binary"):
|
||||
fp.Path = strings.TrimSuffix(rel, ".binary")
|
||||
fp.Op = OpBinary
|
||||
fp.IsBinary = true
|
||||
fp.Content = nil
|
||||
case strings.HasSuffix(rel, ".rename"):
|
||||
fp.Path = strings.TrimSuffix(rel, ".rename")
|
||||
fp.Op = OpRenamed
|
||||
// Parse rename_from from content
|
||||
for _, line := range strings.Split(string(content), "\n") {
|
||||
if strings.HasPrefix(line, "rename_from: ") {
|
||||
fp.OldPath = strings.TrimPrefix(line, "rename_from: ")
|
||||
}
|
||||
}
|
||||
fp.Content = nil
|
||||
default:
|
||||
// Check if content looks like a diff with "new file mode"
|
||||
if strings.Contains(string(content), "new file mode") {
|
||||
fp.Op = OpAdded
|
||||
}
|
||||
}
|
||||
|
||||
return fp
|
||||
}
|
||||
|
||||
func mergePatchEntry(existing, incoming *FilePatch) *FilePatch {
|
||||
switch incoming.Op {
|
||||
case OpDeleted, OpBinary:
|
||||
return incoming
|
||||
case OpRenamed:
|
||||
merged := *incoming
|
||||
if len(existing.Content) > 0 {
|
||||
merged.Content = existing.Content
|
||||
}
|
||||
return &merged
|
||||
}
|
||||
|
||||
switch existing.Op {
|
||||
case OpDeleted, OpBinary:
|
||||
return existing
|
||||
case OpRenamed:
|
||||
merged := *existing
|
||||
merged.Content = incoming.Content
|
||||
return &merged
|
||||
default:
|
||||
return incoming
|
||||
}
|
||||
}
|
||||
@@ -1,77 +0,0 @@
|
||||
package patch
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestReadPatchSetMarkerPrecedence(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
dir := t.TempDir()
|
||||
patchPath := filepath.Join(dir, "chrome", "browser", "foo.cc")
|
||||
if err := os.MkdirAll(filepath.Dir(patchPath), 0o755); err != nil {
|
||||
t.Fatalf("mkdir: %v", err)
|
||||
}
|
||||
|
||||
if err := os.WriteFile(patchPath, []byte("diff --git a/chrome/browser/foo.cc b/chrome/browser/foo.cc\n"), 0o644); err != nil {
|
||||
t.Fatalf("write patch: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(patchPath+".deleted", []byte("deleted: chrome/browser/foo.cc\n"), 0o644); err != nil {
|
||||
t.Fatalf("write marker: %v", err)
|
||||
}
|
||||
|
||||
ps, err := ReadPatchSet(dir)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadPatchSet: %v", err)
|
||||
}
|
||||
|
||||
fp, ok := ps.Patches["chrome/browser/foo.cc"]
|
||||
if !ok {
|
||||
t.Fatalf("missing patch entry")
|
||||
}
|
||||
if fp.Op != OpDeleted {
|
||||
t.Fatalf("expected OpDeleted, got %v", fp.Op)
|
||||
}
|
||||
if len(fp.Content) != 0 {
|
||||
t.Fatalf("expected empty content for deleted marker")
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadPatchSetRenameMergesContent(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
dir := t.TempDir()
|
||||
patchPath := filepath.Join(dir, "chrome", "browser", "new_name.cc")
|
||||
if err := os.MkdirAll(filepath.Dir(patchPath), 0o755); err != nil {
|
||||
t.Fatalf("mkdir: %v", err)
|
||||
}
|
||||
|
||||
diff := "diff --git a/chrome/browser/old_name.cc b/chrome/browser/new_name.cc\nrename from chrome/browser/old_name.cc\nrename to chrome/browser/new_name.cc\n"
|
||||
if err := os.WriteFile(patchPath, []byte(diff), 0o644); err != nil {
|
||||
t.Fatalf("write patch: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(patchPath+".rename", []byte("rename_from: chrome/browser/old_name.cc\nsimilarity: 92\n"), 0o644); err != nil {
|
||||
t.Fatalf("write rename marker: %v", err)
|
||||
}
|
||||
|
||||
ps, err := ReadPatchSet(dir)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadPatchSet: %v", err)
|
||||
}
|
||||
|
||||
fp, ok := ps.Patches["chrome/browser/new_name.cc"]
|
||||
if !ok {
|
||||
t.Fatalf("missing rename patch entry")
|
||||
}
|
||||
if fp.Op != OpRenamed {
|
||||
t.Fatalf("expected OpRenamed, got %v", fp.Op)
|
||||
}
|
||||
if fp.OldPath != "chrome/browser/old_name.cc" {
|
||||
t.Fatalf("unexpected old path: %q", fp.OldPath)
|
||||
}
|
||||
if len(fp.Content) == 0 {
|
||||
t.Fatalf("expected rename patch to keep diff content")
|
||||
}
|
||||
}
|
||||
164
packages/browseros/tools/bdev/internal/patch/repo.go
Normal file
164
packages/browseros/tools/bdev/internal/patch/repo.go
Normal file
@@ -0,0 +1,164 @@
|
||||
package patch
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func LoadRepoPatchSet(patchesDir string, filters []string) (PatchSet, error) {
|
||||
set := PatchSet{}
|
||||
err := filepath.WalkDir(patchesDir, func(fullPath string, d os.DirEntry, walkErr error) error {
|
||||
if walkErr != nil {
|
||||
return walkErr
|
||||
}
|
||||
if d.IsDir() {
|
||||
return nil
|
||||
}
|
||||
relPath, err := filepath.Rel(patchesDir, fullPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
relPath = NormalizeChromiumPath(relPath)
|
||||
patchFile, err := loadPatchFile(fullPath, relPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !PathMatches(patchFile.Path, filters) {
|
||||
return nil
|
||||
}
|
||||
set[patchFile.Path] = patchFile
|
||||
return nil
|
||||
})
|
||||
return set, err
|
||||
}
|
||||
|
||||
func WriteRepoPatchSet(patchesDir string, set PatchSet, scope []string) ([]string, []string, error) {
|
||||
existing, err := LoadRepoPatchSet(patchesDir, nil)
|
||||
if err != nil && !os.IsNotExist(err) {
|
||||
return nil, nil, err
|
||||
}
|
||||
inScope := map[string]bool{}
|
||||
if len(scope) == 0 {
|
||||
for rel := range existing {
|
||||
inScope[rel] = true
|
||||
}
|
||||
for rel := range set {
|
||||
inScope[rel] = true
|
||||
}
|
||||
} else {
|
||||
for _, rel := range scope {
|
||||
inScope[NormalizeChromiumPath(rel)] = true
|
||||
}
|
||||
}
|
||||
|
||||
var written []string
|
||||
var deleted []string
|
||||
for rel := range existing {
|
||||
if !inScope[rel] || set[rel].Path != "" {
|
||||
continue
|
||||
}
|
||||
if err := removePatchVariants(patchesDir, rel); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
deleted = append(deleted, rel)
|
||||
}
|
||||
for rel, patchFile := range set {
|
||||
if !inScope[rel] {
|
||||
continue
|
||||
}
|
||||
if err := removePatchVariants(patchesDir, rel); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
target, body := patchWriteTarget(patchesDir, patchFile)
|
||||
if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
if len(body) == 0 || body[len(body)-1] != '\n' {
|
||||
body = append(body, '\n')
|
||||
}
|
||||
if err := os.WriteFile(target, body, 0o644); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
written = append(written, rel)
|
||||
}
|
||||
return written, deleted, nil
|
||||
}
|
||||
|
||||
func loadPatchFile(fullPath string, relPath string) (FilePatch, error) {
|
||||
switch {
|
||||
case strings.HasSuffix(relPath, ".deleted"):
|
||||
return FilePatch{Path: strings.TrimSuffix(relPath, ".deleted"), Op: OpDelete}, nil
|
||||
case strings.HasSuffix(relPath, ".binary"):
|
||||
return FilePatch{Path: strings.TrimSuffix(relPath, ".binary"), Op: OpBinary, IsBinary: true}, nil
|
||||
case strings.HasSuffix(relPath, ".rename"):
|
||||
body, err := os.ReadFile(fullPath)
|
||||
if err != nil {
|
||||
return FilePatch{}, err
|
||||
}
|
||||
return parseRenameMarker(strings.TrimSuffix(relPath, ".rename"), string(body))
|
||||
default:
|
||||
body, err := os.ReadFile(fullPath)
|
||||
if err != nil {
|
||||
return FilePatch{}, err
|
||||
}
|
||||
set, err := ParseDiffOutput(string(body))
|
||||
if err != nil {
|
||||
return FilePatch{}, fmt.Errorf("parse %s: %w", relPath, err)
|
||||
}
|
||||
for _, patchFile := range set {
|
||||
return patchFile, nil
|
||||
}
|
||||
return FilePatch{}, fmt.Errorf("empty patch file: %s", relPath)
|
||||
}
|
||||
}
|
||||
|
||||
func patchWriteTarget(patchesDir string, patchFile FilePatch) (string, []byte) {
|
||||
switch {
|
||||
case patchFile.Op == OpDelete:
|
||||
return filepath.Join(patchesDir, filepath.FromSlash(patchFile.Path+".deleted")), []byte("File deleted in patch")
|
||||
case patchFile.IsPureRename():
|
||||
body := []byte(fmt.Sprintf("Renamed from: %s\nSimilarity: %d%%", patchFile.OldPath, patchFile.Similarity))
|
||||
return filepath.Join(patchesDir, filepath.FromSlash(patchFile.Path+".rename")), body
|
||||
case patchFile.Op == OpBinary && len(patchFile.Content) == 0:
|
||||
return filepath.Join(patchesDir, filepath.FromSlash(patchFile.Path+".binary")), []byte("Binary file")
|
||||
default:
|
||||
return filepath.Join(patchesDir, filepath.FromSlash(patchFile.Path)), patchFile.Content
|
||||
}
|
||||
}
|
||||
|
||||
func removePatchVariants(patchesDir string, rel string) error {
|
||||
variants := []string{
|
||||
filepath.Join(patchesDir, filepath.FromSlash(rel)),
|
||||
filepath.Join(patchesDir, filepath.FromSlash(rel+".deleted")),
|
||||
filepath.Join(patchesDir, filepath.FromSlash(rel+".binary")),
|
||||
filepath.Join(patchesDir, filepath.FromSlash(rel+".rename")),
|
||||
}
|
||||
for _, variant := range variants {
|
||||
if err := os.Remove(variant); err != nil && !os.IsNotExist(err) {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func parseRenameMarker(rel string, body string) (FilePatch, error) {
|
||||
patchFile := FilePatch{Path: rel, Op: OpRename}
|
||||
for _, line := range strings.Split(body, "\n") {
|
||||
switch {
|
||||
case strings.HasPrefix(line, "Renamed from: "):
|
||||
patchFile.OldPath = NormalizeChromiumPath(strings.TrimPrefix(line, "Renamed from: "))
|
||||
case strings.HasPrefix(line, "Similarity: "):
|
||||
value := strings.TrimSuffix(strings.TrimPrefix(line, "Similarity: "), "%")
|
||||
if similarity, err := strconv.Atoi(strings.TrimSpace(value)); err == nil {
|
||||
patchFile.Similarity = similarity
|
||||
}
|
||||
}
|
||||
}
|
||||
if patchFile.OldPath == "" {
|
||||
return FilePatch{}, fmt.Errorf("rename marker missing source path for %s", rel)
|
||||
}
|
||||
return patchFile, nil
|
||||
}
|
||||
@@ -1,75 +0,0 @@
|
||||
package patch
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// RemoveStale walks chromium_patches/ and removes patches for files
|
||||
// that are NOT in the given PatchSet.
|
||||
func RemoveStale(patchesDir string, current *PatchSet, dryRun bool) ([]string, error) {
|
||||
var stale []string
|
||||
|
||||
err := filepath.Walk(patchesDir, func(path string, info os.FileInfo, err error) error {
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
if info.IsDir() {
|
||||
return nil
|
||||
}
|
||||
|
||||
rel, err := filepath.Rel(patchesDir, path)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Normalize: strip marker suffixes to get the chromium path
|
||||
chromPath := rel
|
||||
chromPath = strings.TrimSuffix(chromPath, ".deleted")
|
||||
chromPath = strings.TrimSuffix(chromPath, ".binary")
|
||||
chromPath = strings.TrimSuffix(chromPath, ".rename")
|
||||
|
||||
if _, exists := current.Patches[chromPath]; !exists {
|
||||
stale = append(stale, rel)
|
||||
if !dryRun {
|
||||
_ = os.Remove(path)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return stale, err
|
||||
}
|
||||
|
||||
// Clean up empty directories
|
||||
if !dryRun {
|
||||
cleanEmptyDirs(patchesDir)
|
||||
}
|
||||
|
||||
return stale, nil
|
||||
}
|
||||
|
||||
func cleanEmptyDirs(root string) {
|
||||
// Walk bottom-up by collecting dirs first then removing empty ones
|
||||
var dirs []string
|
||||
_ = filepath.Walk(root, func(path string, info os.FileInfo, err error) error {
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
if info.IsDir() && path != root {
|
||||
dirs = append(dirs, path)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
// Reverse order (deepest first)
|
||||
for i := len(dirs) - 1; i >= 0; i-- {
|
||||
entries, err := os.ReadDir(dirs[i])
|
||||
if err == nil && len(entries) == 0 {
|
||||
_ = os.Remove(dirs[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,79 +1,80 @@
|
||||
package patch
|
||||
|
||||
type FileOp int
|
||||
|
||||
const (
|
||||
OpModified FileOp = iota
|
||||
OpAdded
|
||||
OpDeleted
|
||||
OpRenamed
|
||||
OpBinary
|
||||
import (
|
||||
"bytes"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func (op FileOp) String() string {
|
||||
switch op {
|
||||
case OpModified:
|
||||
return "M"
|
||||
case OpAdded:
|
||||
return "A"
|
||||
case OpDeleted:
|
||||
return "D"
|
||||
case OpRenamed:
|
||||
return "R"
|
||||
case OpBinary:
|
||||
return "B"
|
||||
default:
|
||||
return "?"
|
||||
}
|
||||
}
|
||||
type FileOp string
|
||||
|
||||
const (
|
||||
OpAdd FileOp = "ADD"
|
||||
OpModify FileOp = "MODIFY"
|
||||
OpDelete FileOp = "DELETE"
|
||||
OpRename FileOp = "RENAME"
|
||||
OpCopy FileOp = "COPY"
|
||||
OpBinary FileOp = "BINARY"
|
||||
)
|
||||
|
||||
type FilePatch struct {
|
||||
Path string
|
||||
Op FileOp
|
||||
Content []byte
|
||||
OldPath string // for renames
|
||||
Similarity int // for renames
|
||||
IsBinary bool
|
||||
Path string `json:"path"`
|
||||
Op FileOp `json:"op"`
|
||||
OldPath string `json:"old_path,omitempty"`
|
||||
Similarity int `json:"similarity,omitempty"`
|
||||
Content []byte `json:"-"`
|
||||
IsBinary bool `json:"is_binary,omitempty"`
|
||||
}
|
||||
|
||||
type PatchSet struct {
|
||||
Base string
|
||||
Patches map[string]*FilePatch // keyed by chromium path
|
||||
type PatchSet map[string]FilePatch
|
||||
|
||||
type DeltaKind string
|
||||
|
||||
const (
|
||||
NeedsApply DeltaKind = "needs_apply"
|
||||
NeedsUpdate DeltaKind = "needs_update"
|
||||
UpToDate DeltaKind = "up_to_date"
|
||||
Orphaned DeltaKind = "orphaned"
|
||||
)
|
||||
|
||||
type Delta struct {
|
||||
Path string `json:"path"`
|
||||
Kind DeltaKind `json:"kind"`
|
||||
Repo *FilePatch `json:"repo,omitempty"`
|
||||
Local *FilePatch `json:"local,omitempty"`
|
||||
}
|
||||
|
||||
func NewPatchSet(base string) *PatchSet {
|
||||
return &PatchSet{
|
||||
Base: base,
|
||||
Patches: make(map[string]*FilePatch),
|
||||
func NormalizeChromiumPath(raw string) string {
|
||||
clean := filepath.ToSlash(raw)
|
||||
return strings.TrimPrefix(path.Clean(clean), "./")
|
||||
}
|
||||
|
||||
func PathMatches(rel string, filters []string) bool {
|
||||
if IsInternalPath(rel) {
|
||||
return false
|
||||
}
|
||||
if len(filters) == 0 {
|
||||
return true
|
||||
}
|
||||
candidate := NormalizeChromiumPath(rel)
|
||||
for _, filter := range filters {
|
||||
scope := NormalizeChromiumPath(filter)
|
||||
if candidate == scope || strings.HasPrefix(candidate, scope+"/") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
type PushResult struct {
|
||||
Modified []string
|
||||
Added []string
|
||||
Deleted []string
|
||||
Stale []string
|
||||
Unchanged []string
|
||||
func IsInternalPath(rel string) bool {
|
||||
candidate := NormalizeChromiumPath(rel)
|
||||
return candidate == ".bdev" || strings.HasPrefix(candidate, ".bdev/")
|
||||
}
|
||||
|
||||
func (r *PushResult) Total() int {
|
||||
return len(r.Modified) + len(r.Added) + len(r.Deleted)
|
||||
}
|
||||
|
||||
type PullResult struct {
|
||||
Applied []string
|
||||
Skipped []string
|
||||
Reverted []string
|
||||
LocalOnly []string
|
||||
Conflicts []ConflictInfo
|
||||
Deleted []string
|
||||
}
|
||||
|
||||
type ConflictInfo struct {
|
||||
File string
|
||||
RejectFile string
|
||||
PatchFile string
|
||||
HunksTotal int
|
||||
HunksFailed int
|
||||
Error string
|
||||
func (p FilePatch) IsPureRename() bool {
|
||||
if p.Op != OpRename {
|
||||
return false
|
||||
}
|
||||
return !bytes.Contains(p.Content, []byte("\n@@")) && !bytes.Contains(p.Content, []byte("GIT binary patch"))
|
||||
}
|
||||
|
||||
215
packages/browseros/tools/bdev/internal/patch/workspace.go
Normal file
215
packages/browseros/tools/bdev/internal/patch/workspace.go
Normal file
@@ -0,0 +1,215 @@
|
||||
package patch
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"github.com/browseros-ai/BrowserOS/packages/browseros/tools/bdev/internal/git"
|
||||
)
|
||||
|
||||
func BuildWorkingTreePatchSet(ctx context.Context, workspacePath string, base string, filters []string) (PatchSet, error) {
|
||||
diff, err := git.DiffText(ctx, workspacePath, base)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
set, err := ParseDiffOutput(diff)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
untracked, err := git.ListUntracked(ctx, workspacePath, filters)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, rel := range untracked {
|
||||
diffText, err := git.DiffNoIndex(ctx, workspacePath, rel)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
untrackedSet, err := ParseDiffOutput(diffText)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for patchPath, patchFile := range untrackedSet {
|
||||
set[patchPath] = patchFile
|
||||
}
|
||||
}
|
||||
return filterSet(set, filters), nil
|
||||
}
|
||||
|
||||
func BuildCommitPatchSet(ctx context.Context, workspacePath string, ref string, base string, filters []string) (PatchSet, error) {
|
||||
if base == "" {
|
||||
diff, err := git.DiffText(ctx, workspacePath, ref+"^.."+ref)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
set, err := ParseDiffOutput(diff)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return filterSet(set, filters), nil
|
||||
}
|
||||
changes, err := git.DiffTreeNameStatus(ctx, workspacePath, ref, filters)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return buildBaseScopedSet(ctx, workspacePath, ref, base, changes)
|
||||
}
|
||||
|
||||
func BuildRangePatchSet(ctx context.Context, workspacePath string, start string, end string, base string, squash bool, filters []string) (PatchSet, error) {
|
||||
if squash {
|
||||
if base == "" {
|
||||
diff, err := git.DiffText(ctx, workspacePath, start+".."+end)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
set, err := ParseDiffOutput(diff)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return filterSet(set, filters), nil
|
||||
}
|
||||
changes, err := git.DiffNameStatusBetween(ctx, workspacePath, start, end, filters)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return buildBaseScopedSet(ctx, workspacePath, end, base, changes)
|
||||
}
|
||||
|
||||
commits, err := git.RevListRange(ctx, workspacePath, start, end)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
set := PatchSet{}
|
||||
seen := map[string]bool{}
|
||||
for _, commit := range commits {
|
||||
var current PatchSet
|
||||
if base == "" {
|
||||
diff, err := git.DiffText(ctx, workspacePath, commit+"^.."+commit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
current, err = ParseDiffOutput(diff)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
} else {
|
||||
changes, err := git.DiffTreeNameStatus(ctx, workspacePath, commit, filters)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
current, err = buildBaseScopedSet(ctx, workspacePath, commit, base, changes)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
for rel, patchFile := range filterSet(current, filters) {
|
||||
if base != "" {
|
||||
set[rel] = patchFile
|
||||
continue
|
||||
}
|
||||
if seen[rel] {
|
||||
continue
|
||||
}
|
||||
set[rel] = patchFile
|
||||
seen[rel] = true
|
||||
}
|
||||
}
|
||||
return set, nil
|
||||
}
|
||||
|
||||
func buildBaseScopedSet(ctx context.Context, workspacePath string, ref string, base string, changes []git.FileChange) (PatchSet, error) {
|
||||
set := PatchSet{}
|
||||
for _, change := range changes {
|
||||
rel := NormalizeChromiumPath(change.Path)
|
||||
diff, err := git.DiffText(ctx, workspacePath, base, ref, "--", rel)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
switch {
|
||||
case strings.TrimSpace(diff) != "":
|
||||
patches, err := ParseDiffOutput(diff)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for patchPath, patchFile := range patches {
|
||||
set[patchPath] = patchFile
|
||||
}
|
||||
case change.Status == "D":
|
||||
exists, err := git.FileExistsAtCommit(ctx, workspacePath, base, rel)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if exists {
|
||||
set[rel] = FilePatch{Path: rel, Op: OpDelete}
|
||||
}
|
||||
case change.Status == "A":
|
||||
content, err := git.ShowFile(ctx, workspacePath, ref, rel)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
set[rel] = syntheticAddPatch(rel, content)
|
||||
}
|
||||
}
|
||||
return set, nil
|
||||
}
|
||||
|
||||
func filterSet(set PatchSet, filters []string) PatchSet {
|
||||
filtered := PatchSet{}
|
||||
for rel, patchFile := range set {
|
||||
if !PathMatches(rel, filters) {
|
||||
continue
|
||||
}
|
||||
filtered[rel] = patchFile
|
||||
}
|
||||
return filtered
|
||||
}
|
||||
|
||||
func ScopeFromSet(set PatchSet) []string {
|
||||
paths := make([]string, 0, len(set))
|
||||
for rel := range set {
|
||||
paths = append(paths, rel)
|
||||
}
|
||||
slices.Sort(paths)
|
||||
return paths
|
||||
}
|
||||
|
||||
func RejectPath(workspacePath string, rel string) string {
|
||||
return filepath.Join(workspacePath, filepath.FromSlash(rel+".rej"))
|
||||
}
|
||||
|
||||
func syntheticAddPatch(rel string, content []byte) FilePatch {
|
||||
body := string(content)
|
||||
if body != "" && body[len(body)-1] != '\n' {
|
||||
body += "\n"
|
||||
}
|
||||
patchBody := fmt.Sprintf(
|
||||
"diff --git a/%s b/%s\nnew file mode 100644\n--- /dev/null\n+++ b/%s\n@@ -0,0 +1,%d @@\n%s",
|
||||
rel,
|
||||
rel,
|
||||
rel,
|
||||
countLines(body),
|
||||
prefixLines(body, "+"),
|
||||
)
|
||||
return FilePatch{Path: rel, Op: OpAdd, Content: []byte(patchBody)}
|
||||
}
|
||||
|
||||
func countLines(body string) int {
|
||||
if body == "" {
|
||||
return 0
|
||||
}
|
||||
return len(strings.Split(strings.TrimSuffix(body, "\n"), "\n"))
|
||||
}
|
||||
|
||||
func prefixLines(body string, prefix string) string {
|
||||
lines := strings.Split(strings.TrimSuffix(body, "\n"), "\n")
|
||||
for idx, line := range lines {
|
||||
lines[idx] = prefix + line
|
||||
}
|
||||
if len(lines) == 0 {
|
||||
return ""
|
||||
}
|
||||
return strings.Join(lines, "\n") + "\n"
|
||||
}
|
||||
@@ -1,119 +0,0 @@
|
||||
package patch
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
|
||||
"golang.org/x/sync/errgroup"
|
||||
)
|
||||
|
||||
// WritePatchSet writes patches to the chromium_patches/ directory.
|
||||
func WritePatchSet(patchesDir string, ps *PatchSet, dryRun bool) error {
|
||||
g, _ := errgroup.WithContext(context.Background())
|
||||
g.SetLimit(runtime.NumCPU())
|
||||
|
||||
for _, fp := range ps.Patches {
|
||||
fp := fp
|
||||
g.Go(func() error {
|
||||
return writeSinglePatch(patchesDir, fp, dryRun)
|
||||
})
|
||||
}
|
||||
|
||||
return g.Wait()
|
||||
}
|
||||
|
||||
func writeSinglePatch(patchesDir string, fp *FilePatch, dryRun bool) error {
|
||||
if dryRun {
|
||||
return nil
|
||||
}
|
||||
|
||||
switch fp.Op {
|
||||
case OpDeleted:
|
||||
if err := cleanupArtifacts(patchesDir, fp.Path, fp.Path+".deleted"); err != nil {
|
||||
return err
|
||||
}
|
||||
return writeDeletedMarker(patchesDir, fp)
|
||||
case OpBinary:
|
||||
if err := cleanupArtifacts(patchesDir, fp.Path, fp.Path+".binary"); err != nil {
|
||||
return err
|
||||
}
|
||||
return writeBinaryMarker(patchesDir, fp)
|
||||
case OpRenamed:
|
||||
if err := cleanupArtifacts(patchesDir, fp.Path, fp.Path, fp.Path+".rename"); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := writePatchFile(patchesDir, fp); err != nil {
|
||||
return err
|
||||
}
|
||||
return writeRenameMarker(patchesDir, fp)
|
||||
default:
|
||||
if err := cleanupArtifacts(patchesDir, fp.Path, fp.Path); err != nil {
|
||||
return err
|
||||
}
|
||||
return writePatchFile(patchesDir, fp)
|
||||
}
|
||||
}
|
||||
|
||||
func writePatchFile(patchesDir string, fp *FilePatch) error {
|
||||
dest := filepath.Join(patchesDir, fp.Path)
|
||||
if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil {
|
||||
return fmt.Errorf("creating directory: %w", err)
|
||||
}
|
||||
return os.WriteFile(dest, fp.Content, 0o644)
|
||||
}
|
||||
|
||||
func writeDeletedMarker(patchesDir string, fp *FilePatch) error {
|
||||
dest := filepath.Join(patchesDir, fp.Path+".deleted")
|
||||
if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil {
|
||||
return fmt.Errorf("creating directory: %w", err)
|
||||
}
|
||||
content := fmt.Sprintf("deleted: %s\n", fp.Path)
|
||||
return os.WriteFile(dest, []byte(content), 0o644)
|
||||
}
|
||||
|
||||
func writeBinaryMarker(patchesDir string, fp *FilePatch) error {
|
||||
dest := filepath.Join(patchesDir, fp.Path+".binary")
|
||||
if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil {
|
||||
return fmt.Errorf("creating directory: %w", err)
|
||||
}
|
||||
content := fmt.Sprintf("binary: %s\n", fp.Path)
|
||||
return os.WriteFile(dest, []byte(content), 0o644)
|
||||
}
|
||||
|
||||
func writeRenameMarker(patchesDir string, fp *FilePatch) error {
|
||||
dest := filepath.Join(patchesDir, fp.Path+".rename")
|
||||
if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil {
|
||||
return fmt.Errorf("creating directory: %w", err)
|
||||
}
|
||||
content := fmt.Sprintf("rename_from: %s\nsimilarity: %d\n", fp.OldPath, fp.Similarity)
|
||||
return os.WriteFile(dest, []byte(content), 0o644)
|
||||
}
|
||||
|
||||
func cleanupArtifacts(patchesDir, path string, keep ...string) error {
|
||||
keepSet := make(map[string]bool, len(keep))
|
||||
for _, k := range keep {
|
||||
keepSet[k] = true
|
||||
}
|
||||
|
||||
candidates := []string{
|
||||
path,
|
||||
path + ".deleted",
|
||||
path + ".binary",
|
||||
path + ".rename",
|
||||
}
|
||||
|
||||
for _, rel := range candidates {
|
||||
if keepSet[rel] {
|
||||
continue
|
||||
}
|
||||
full := filepath.Join(patchesDir, rel)
|
||||
if err := os.Remove(full); err != nil && !os.IsNotExist(err) {
|
||||
return fmt.Errorf("removing stale artifact %s: %w", rel, err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -1,87 +0,0 @@
|
||||
package patch
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestWritePatchSetDeletedCleansSiblingArtifacts(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
dir := t.TempDir()
|
||||
chromPath := "components/foo/bar.cc"
|
||||
basePath := filepath.Join(dir, chromPath)
|
||||
|
||||
if err := os.MkdirAll(filepath.Dir(basePath), 0o755); err != nil {
|
||||
t.Fatalf("mkdir: %v", err)
|
||||
}
|
||||
|
||||
preExisting := []string{
|
||||
basePath,
|
||||
basePath + ".binary",
|
||||
basePath + ".rename",
|
||||
}
|
||||
for _, p := range preExisting {
|
||||
if err := os.WriteFile(p, []byte("old"), 0o644); err != nil {
|
||||
t.Fatalf("write %s: %v", p, err)
|
||||
}
|
||||
}
|
||||
|
||||
ps := NewPatchSet("")
|
||||
ps.Patches[chromPath] = &FilePatch{Path: chromPath, Op: OpDeleted}
|
||||
if err := WritePatchSet(dir, ps, false); err != nil {
|
||||
t.Fatalf("WritePatchSet: %v", err)
|
||||
}
|
||||
|
||||
if _, err := os.Stat(basePath + ".deleted"); err != nil {
|
||||
t.Fatalf("expected deleted marker: %v", err)
|
||||
}
|
||||
for _, stale := range preExisting {
|
||||
if _, err := os.Stat(stale); !os.IsNotExist(err) {
|
||||
t.Fatalf("expected stale artifact removed: %s", stale)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestWritePatchSetRenameKeepsPatchAndRenameMarker(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
dir := t.TempDir()
|
||||
chromPath := "content/new_file.cc"
|
||||
basePath := filepath.Join(dir, chromPath)
|
||||
if err := os.MkdirAll(filepath.Dir(basePath), 0o755); err != nil {
|
||||
t.Fatalf("mkdir: %v", err)
|
||||
}
|
||||
|
||||
if err := os.WriteFile(basePath+".deleted", []byte("stale"), 0o644); err != nil {
|
||||
t.Fatalf("seed marker: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(basePath+".binary", []byte("stale"), 0o644); err != nil {
|
||||
t.Fatalf("seed marker: %v", err)
|
||||
}
|
||||
|
||||
ps := NewPatchSet("")
|
||||
ps.Patches[chromPath] = &FilePatch{
|
||||
Path: chromPath,
|
||||
Op: OpRenamed,
|
||||
OldPath: "content/old_file.cc",
|
||||
Similarity: 95,
|
||||
Content: []byte("diff --git a/content/old_file.cc b/content/new_file.cc\n"),
|
||||
}
|
||||
if err := WritePatchSet(dir, ps, false); err != nil {
|
||||
t.Fatalf("WritePatchSet: %v", err)
|
||||
}
|
||||
|
||||
if _, err := os.Stat(basePath); err != nil {
|
||||
t.Fatalf("expected rename patch content file: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(basePath + ".rename"); err != nil {
|
||||
t.Fatalf("expected rename marker file: %v", err)
|
||||
}
|
||||
for _, stale := range []string{basePath + ".deleted", basePath + ".binary"} {
|
||||
if _, err := os.Stat(stale); !os.IsNotExist(err) {
|
||||
t.Fatalf("expected stale artifact removed: %s", stale)
|
||||
}
|
||||
}
|
||||
}
|
||||
61
packages/browseros/tools/bdev/internal/repo/repo.go
Normal file
61
packages/browseros/tools/bdev/internal/repo/repo.go
Normal file
@@ -0,0 +1,61 @@
|
||||
package repo
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type Info struct {
|
||||
Root string `json:"root"`
|
||||
PatchesDir string `json:"patches_dir"`
|
||||
BaseCommit string `json:"base_commit"`
|
||||
}
|
||||
|
||||
func Discover(start string) (string, error) {
|
||||
abs, err := filepath.Abs(start)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
current := filepath.Clean(abs)
|
||||
for {
|
||||
if hasRepoMarkers(current) {
|
||||
return current, nil
|
||||
}
|
||||
next := filepath.Dir(current)
|
||||
if next == current {
|
||||
break
|
||||
}
|
||||
current = next
|
||||
}
|
||||
return "", fmt.Errorf("unable to find patches repo from %s", start)
|
||||
}
|
||||
|
||||
func Load(root string) (*Info, error) {
|
||||
abs, err := filepath.Abs(root)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
clean := filepath.Clean(abs)
|
||||
if !hasRepoMarkers(clean) {
|
||||
return nil, fmt.Errorf("not a browseros patches repo: %s", clean)
|
||||
}
|
||||
base, err := os.ReadFile(filepath.Join(clean, "BASE_COMMIT"))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &Info{
|
||||
Root: clean,
|
||||
PatchesDir: filepath.Join(clean, "chromium_patches"),
|
||||
BaseCommit: strings.TrimSpace(string(base)),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func hasRepoMarkers(root string) bool {
|
||||
if _, err := os.Stat(filepath.Join(root, "BASE_COMMIT")); err != nil {
|
||||
return false
|
||||
}
|
||||
info, err := os.Stat(filepath.Join(root, "chromium_patches"))
|
||||
return err == nil && info.IsDir()
|
||||
}
|
||||
98
packages/browseros/tools/bdev/internal/resolve/resolve.go
Normal file
98
packages/browseros/tools/bdev/internal/resolve/resolve.go
Normal file
@@ -0,0 +1,98 @@
|
||||
package resolve
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/browseros-ai/BrowserOS/packages/browseros/tools/bdev/internal/patch"
|
||||
"github.com/browseros-ai/BrowserOS/packages/browseros/tools/bdev/internal/workspace"
|
||||
)
|
||||
|
||||
type Operation struct {
|
||||
ChromiumPath string `json:"chromium_path"`
|
||||
PatchRel string `json:"patch_rel"`
|
||||
Op patch.FileOp `json:"op"`
|
||||
OldPath string `json:"old_path,omitempty"`
|
||||
RejectPath string `json:"reject_path,omitempty"`
|
||||
Message string `json:"message,omitempty"`
|
||||
}
|
||||
|
||||
type State struct {
|
||||
Workspace string `json:"workspace"`
|
||||
RepoRoot string `json:"repo_root"`
|
||||
BaseCommit string `json:"base_commit"`
|
||||
RepoRev string `json:"repo_rev,omitempty"`
|
||||
Mode string `json:"mode,omitempty"`
|
||||
Current int `json:"current"`
|
||||
Operations []Operation `json:"operations"`
|
||||
Resolved []string `json:"resolved,omitempty"`
|
||||
Skipped []string `json:"skipped,omitempty"`
|
||||
}
|
||||
|
||||
func Path(workspacePath string) string {
|
||||
return filepath.Join(workspace.StateDir(workspacePath), "resolve.json")
|
||||
}
|
||||
|
||||
func Exists(workspacePath string) bool {
|
||||
_, err := os.Stat(Path(workspacePath))
|
||||
return err == nil
|
||||
}
|
||||
|
||||
func Load(workspacePath string) (*State, error) {
|
||||
data, err := os.ReadFile(Path(workspacePath))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var state State
|
||||
if err := json.Unmarshal(data, &state); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &state, nil
|
||||
}
|
||||
|
||||
func Save(workspacePath string, state *State) error {
|
||||
if err := os.MkdirAll(workspace.StateDir(workspacePath), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
body, err := json.MarshalIndent(state, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(Path(workspacePath), append(body, '\n'), 0o644)
|
||||
}
|
||||
|
||||
func Delete(workspacePath string) error {
|
||||
if err := os.Remove(Path(workspacePath)); err != nil && !os.IsNotExist(err) {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func FindActive(reg *workspace.Registry, cwd string) (workspace.Entry, error) {
|
||||
if ws, err := workspace.Detect(reg, cwd); err == nil && Exists(ws.Path) {
|
||||
return ws, nil
|
||||
}
|
||||
var active []workspace.Entry
|
||||
for _, ws := range reg.Workspaces {
|
||||
if Exists(ws.Path) {
|
||||
active = append(active, ws)
|
||||
}
|
||||
}
|
||||
switch len(active) {
|
||||
case 0:
|
||||
return workspace.Entry{}, fmt.Errorf(`no active conflict resolution found; run "bdev apply" or "bdev sync --rebase" first`)
|
||||
case 1:
|
||||
return active[0], nil
|
||||
default:
|
||||
return workspace.Entry{}, fmt.Errorf("multiple workspaces have active conflicts; run from inside the target workspace")
|
||||
}
|
||||
}
|
||||
|
||||
func (s *State) CurrentOperation() (Operation, error) {
|
||||
if s.Current < 0 || s.Current >= len(s.Operations) {
|
||||
return Operation{}, fmt.Errorf("no active conflict remaining")
|
||||
}
|
||||
return s.Operations[s.Current], nil
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
package resolve
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/browseros-ai/BrowserOS/packages/browseros/tools/bdev/internal/patch"
|
||||
"github.com/browseros-ai/BrowserOS/packages/browseros/tools/bdev/internal/workspace"
|
||||
)
|
||||
|
||||
func TestStateRoundTrip(t *testing.T) {
|
||||
workspacePath := t.TempDir()
|
||||
state := &State{
|
||||
Workspace: workspacePath,
|
||||
RepoRoot: "/tmp/repo",
|
||||
BaseCommit: "abc123",
|
||||
Current: 1,
|
||||
Operations: []Operation{{
|
||||
ChromiumPath: "chrome/browser/foo.cc",
|
||||
PatchRel: "chrome/browser/foo.cc",
|
||||
Op: patch.OpModify,
|
||||
}},
|
||||
}
|
||||
if err := Save(workspacePath, state); err != nil {
|
||||
t.Fatalf("Save: %v", err)
|
||||
}
|
||||
loaded, err := Load(workspacePath)
|
||||
if err != nil {
|
||||
t.Fatalf("Load: %v", err)
|
||||
}
|
||||
if loaded.BaseCommit != state.BaseCommit || loaded.Current != state.Current {
|
||||
t.Fatalf("state mismatch: %#v", loaded)
|
||||
}
|
||||
if err := Delete(workspacePath); err != nil {
|
||||
t.Fatalf("Delete: %v", err)
|
||||
}
|
||||
if Exists(workspacePath) {
|
||||
t.Fatalf("expected resolve state to be deleted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFindActivePrefersCurrentWorkspace(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
workspaceA := workspace.Entry{Name: "a", Path: root + "/a"}
|
||||
workspaceB := workspace.Entry{Name: "b", Path: root + "/b"}
|
||||
for _, ws := range []workspace.Entry{workspaceA, workspaceB} {
|
||||
if err := Save(ws.Path, &State{Workspace: ws.Path}); err != nil {
|
||||
t.Fatalf("save %s: %v", ws.Name, err)
|
||||
}
|
||||
}
|
||||
|
||||
reg := &workspace.Registry{Workspaces: []workspace.Entry{workspaceA, workspaceB}}
|
||||
active, err := FindActive(reg, workspaceB.Path)
|
||||
if err != nil {
|
||||
t.Fatalf("FindActive: %v", err)
|
||||
}
|
||||
if active.Name != workspaceB.Name {
|
||||
t.Fatalf("expected %q, got %q", workspaceB.Name, active.Name)
|
||||
}
|
||||
}
|
||||
@@ -1,58 +0,0 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var (
|
||||
StepPrefix = TitleStyle.Render(">")
|
||||
InfoPrefix = SubtitleStyle.Render("i")
|
||||
OkPrefix = SuccessStyle.Render("✓")
|
||||
WarnPrefix = WarningStyle.Render("!")
|
||||
ErrorPrefix = ErrorStyle.Render("x")
|
||||
)
|
||||
|
||||
type Activity struct {
|
||||
Verbose bool
|
||||
}
|
||||
|
||||
func NewActivity(verbose bool) *Activity {
|
||||
return &Activity{Verbose: verbose}
|
||||
}
|
||||
|
||||
func (a *Activity) Header(title string) {
|
||||
fmt.Println(TitleStyle.Render(title))
|
||||
fmt.Println()
|
||||
}
|
||||
|
||||
func (a *Activity) Step(format string, args ...any) {
|
||||
fmt.Printf(" %s %s\n", StepPrefix, fmt.Sprintf(format, args...))
|
||||
}
|
||||
|
||||
func (a *Activity) Info(format string, args ...any) {
|
||||
fmt.Printf(" %s %s\n", InfoPrefix, fmt.Sprintf(format, args...))
|
||||
}
|
||||
|
||||
func (a *Activity) Detail(format string, args ...any) {
|
||||
if !a.Verbose {
|
||||
return
|
||||
}
|
||||
fmt.Printf(" %s %s\n", InfoPrefix, fmt.Sprintf(format, args...))
|
||||
}
|
||||
|
||||
func (a *Activity) Success(format string, args ...any) {
|
||||
fmt.Printf(" %s %s\n", OkPrefix, fmt.Sprintf(format, args...))
|
||||
}
|
||||
|
||||
func (a *Activity) Warn(format string, args ...any) {
|
||||
fmt.Printf(" %s %s\n", WarnPrefix, fmt.Sprintf(format, args...))
|
||||
}
|
||||
|
||||
func (a *Activity) Error(format string, args ...any) {
|
||||
fmt.Printf(" %s %s\n", ErrorPrefix, fmt.Sprintf(format, args...))
|
||||
}
|
||||
|
||||
func (a *Activity) Divider() {
|
||||
fmt.Println(MutedStyle.Render(strings.Repeat("-", 58)))
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"bdev/internal/patch"
|
||||
)
|
||||
|
||||
func RenderPullResult(r *patch.PullResult) string {
|
||||
var b strings.Builder
|
||||
|
||||
b.WriteString(TitleStyle.Render("bdev pull"))
|
||||
b.WriteString("\n\n")
|
||||
|
||||
for _, f := range r.Applied {
|
||||
b.WriteString(fmt.Sprintf(" %s %s\n", SuccessStyle.Render("+"), f))
|
||||
}
|
||||
for _, c := range r.Conflicts {
|
||||
b.WriteString(fmt.Sprintf(" %s %s\n", ErrorStyle.Render("x"), c.File))
|
||||
}
|
||||
for _, f := range r.Deleted {
|
||||
b.WriteString(fmt.Sprintf(" %s %s\n", DeletedPrefix, f))
|
||||
}
|
||||
for _, f := range r.Reverted {
|
||||
b.WriteString(fmt.Sprintf(" %s %s %s\n", ModifiedPrefix, f, MutedStyle.Render("(reverted to base)")))
|
||||
}
|
||||
for _, f := range r.LocalOnly {
|
||||
b.WriteString(fmt.Sprintf(" %s %s %s\n", SkippedPrefix, f, MutedStyle.Render("(local-only, kept)")))
|
||||
}
|
||||
if len(r.Skipped) > 0 {
|
||||
b.WriteString(fmt.Sprintf(" %s %s\n", SkippedPrefix,
|
||||
MutedStyle.Render(fmt.Sprintf("%d files skipped (already up to date)", len(r.Skipped)))))
|
||||
}
|
||||
|
||||
b.WriteString("\n")
|
||||
|
||||
total := len(r.Applied) + len(r.Deleted) + len(r.Reverted) + len(r.LocalOnly) + len(r.Conflicts) + len(r.Skipped)
|
||||
summary := fmt.Sprintf("Pulled %d patch paths", total)
|
||||
b.WriteString(SuccessStyle.Render(summary))
|
||||
b.WriteString(MutedStyle.Render(fmt.Sprintf(" (%d applied, %d deleted, %d reverted, %d local-only, %d conflicts, %d skipped)",
|
||||
len(r.Applied), len(r.Deleted), len(r.Reverted), len(r.LocalOnly), len(r.Conflicts), len(r.Skipped))))
|
||||
b.WriteString("\n")
|
||||
|
||||
return b.String()
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"bdev/internal/patch"
|
||||
)
|
||||
|
||||
func RenderConflictReport(conflicts []patch.ConflictInfo) string {
|
||||
if len(conflicts) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
var b strings.Builder
|
||||
|
||||
b.WriteString("\n")
|
||||
b.WriteString(ErrorStyle.Render("=== CONFLICT REPORT ==="))
|
||||
b.WriteString("\n\n")
|
||||
|
||||
for i, c := range conflicts {
|
||||
b.WriteString(fmt.Sprintf("file: %s\n", c.File))
|
||||
b.WriteString(fmt.Sprintf("reject_file: %s\n", c.RejectFile))
|
||||
b.WriteString(fmt.Sprintf("patch_file: %s\n", c.PatchFile))
|
||||
if c.HunksTotal > 0 {
|
||||
b.WriteString(fmt.Sprintf("hunks_total: %d\n", c.HunksTotal))
|
||||
b.WriteString(fmt.Sprintf("hunks_failed: %d\n", c.HunksFailed))
|
||||
}
|
||||
if i < len(conflicts)-1 {
|
||||
b.WriteString("---\n")
|
||||
}
|
||||
}
|
||||
|
||||
return ConflictBox.Render(b.String())
|
||||
}
|
||||
|
||||
func RenderConflictReportJSON(conflicts []patch.ConflictInfo) string {
|
||||
data, _ := json.MarshalIndent(conflicts, "", " ")
|
||||
return string(data)
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
package ui
|
||||
|
||||
import "github.com/charmbracelet/lipgloss"
|
||||
|
||||
var (
|
||||
TitleStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("12"))
|
||||
SubtitleStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("8"))
|
||||
|
||||
SuccessStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("10"))
|
||||
WarningStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("11"))
|
||||
ErrorStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("9"))
|
||||
MutedStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("8"))
|
||||
|
||||
ModifiedPrefix = lipgloss.NewStyle().Foreground(lipgloss.Color("11")).Render("M")
|
||||
AddedPrefix = lipgloss.NewStyle().Foreground(lipgloss.Color("10")).Render("A")
|
||||
DeletedPrefix = lipgloss.NewStyle().Foreground(lipgloss.Color("9")).Render("D")
|
||||
SkippedPrefix = lipgloss.NewStyle().Foreground(lipgloss.Color("8")).Render("~")
|
||||
|
||||
ConflictBox = lipgloss.NewStyle().
|
||||
Border(lipgloss.RoundedBorder()).
|
||||
BorderForeground(lipgloss.Color("9")).
|
||||
Padding(0, 1)
|
||||
|
||||
StatusBox = lipgloss.NewStyle().
|
||||
Border(lipgloss.RoundedBorder()).
|
||||
BorderForeground(lipgloss.Color("12")).
|
||||
Padding(0, 1)
|
||||
|
||||
LabelStyle = lipgloss.NewStyle().Width(16).Foreground(lipgloss.Color("8"))
|
||||
ValueStyle = lipgloss.NewStyle().Bold(true)
|
||||
)
|
||||
84
packages/browseros/tools/bdev/internal/ui/ui.go
Normal file
84
packages/browseros/tools/bdev/internal/ui/ui.go
Normal file
@@ -0,0 +1,84 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
"github.com/charmbracelet/lipgloss/table"
|
||||
)
|
||||
|
||||
var (
|
||||
clrCyan = lipgloss.Color("6")
|
||||
clrBlue = lipgloss.Color("12")
|
||||
clrGreen = lipgloss.Color("2")
|
||||
clrHiGreen = lipgloss.Color("10")
|
||||
clrYellow = lipgloss.Color("11")
|
||||
clrRed = lipgloss.Color("9")
|
||||
clrGray = lipgloss.Color("8")
|
||||
)
|
||||
|
||||
var (
|
||||
TitleStyle = lipgloss.NewStyle().Bold(true).Foreground(clrCyan)
|
||||
HeaderStyle = lipgloss.NewStyle().Bold(true).Foreground(clrCyan)
|
||||
CommandStyle = lipgloss.NewStyle().Foreground(clrHiGreen)
|
||||
AliasStyle = lipgloss.NewStyle().Foreground(clrYellow)
|
||||
HintStyle = lipgloss.NewStyle().Faint(true)
|
||||
SuccessStyle = lipgloss.NewStyle().Foreground(clrGreen).Bold(true)
|
||||
WarningStyle = lipgloss.NewStyle().Foreground(clrYellow).Bold(true)
|
||||
ErrorStyle = lipgloss.NewStyle().Foreground(clrRed).Bold(true)
|
||||
InfoStyle = lipgloss.NewStyle().Foreground(clrBlue)
|
||||
MutedStyle = lipgloss.NewStyle().Foreground(clrGray)
|
||||
)
|
||||
|
||||
func Title(s string) string {
|
||||
return TitleStyle.Render(s)
|
||||
}
|
||||
|
||||
func Header(s string) string {
|
||||
return HeaderStyle.Render(s)
|
||||
}
|
||||
|
||||
func Command(s string) string {
|
||||
return CommandStyle.Render(s)
|
||||
}
|
||||
|
||||
func Aliases(aliases []string) string {
|
||||
return AliasStyle.Render(fmt.Sprintf("(aliases: %s)", strings.Join(aliases, ", ")))
|
||||
}
|
||||
|
||||
func Hint(s string) string {
|
||||
return HintStyle.Render(s)
|
||||
}
|
||||
|
||||
func Success(s string) string {
|
||||
return SuccessStyle.Render(s)
|
||||
}
|
||||
|
||||
func Warning(s string) string {
|
||||
return WarningStyle.Render(s)
|
||||
}
|
||||
|
||||
func Error(s string) string {
|
||||
return ErrorStyle.Render(s)
|
||||
}
|
||||
|
||||
func Info(s string) string {
|
||||
return InfoStyle.Render(s)
|
||||
}
|
||||
|
||||
func Muted(s string) string {
|
||||
return MutedStyle.Render(s)
|
||||
}
|
||||
|
||||
func RenderTable(headers []string, rows [][]string) string {
|
||||
t := table.New().Border(lipgloss.HiddenBorder()).Headers(headers...).Rows(rows...)
|
||||
t = t.StyleFunc(func(row, col int) lipgloss.Style {
|
||||
style := lipgloss.NewStyle().PaddingRight(2)
|
||||
if row == table.HeaderRow {
|
||||
return style.Bold(true).Faint(true)
|
||||
}
|
||||
return style
|
||||
})
|
||||
return t.String()
|
||||
}
|
||||
63
packages/browseros/tools/bdev/internal/workspace/config.go
Normal file
63
packages/browseros/tools/bdev/internal/workspace/config.go
Normal file
@@ -0,0 +1,63 @@
|
||||
package workspace
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
Version int `yaml:"version" json:"version"`
|
||||
PatchesRepo string `yaml:"patches_repo,omitempty" json:"patches_repo,omitempty"`
|
||||
}
|
||||
|
||||
func ConfigDir() string {
|
||||
if xdg := os.Getenv("XDG_CONFIG_HOME"); xdg != "" {
|
||||
return filepath.Join(xdg, "bdev")
|
||||
}
|
||||
home, _ := os.UserHomeDir()
|
||||
return filepath.Join(home, ".config", "bdev")
|
||||
}
|
||||
|
||||
func ConfigPath() string {
|
||||
return filepath.Join(ConfigDir(), "config.yaml")
|
||||
}
|
||||
|
||||
func RegistryPath() string {
|
||||
return filepath.Join(ConfigDir(), "workspaces.yaml")
|
||||
}
|
||||
|
||||
func LoadConfig() (*Config, error) {
|
||||
data, err := os.ReadFile(ConfigPath())
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return &Config{Version: 1}, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
var cfg Config
|
||||
if err := yaml.Unmarshal(data, &cfg); err != nil {
|
||||
return nil, fmt.Errorf("parse config: %w", err)
|
||||
}
|
||||
if cfg.Version == 0 {
|
||||
cfg.Version = 1
|
||||
}
|
||||
return &cfg, nil
|
||||
}
|
||||
|
||||
func SaveConfig(cfg *Config) error {
|
||||
if cfg.Version == 0 {
|
||||
cfg.Version = 1
|
||||
}
|
||||
if err := os.MkdirAll(ConfigDir(), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
body, err := yaml.Marshal(cfg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
header := "# bdev configuration\n\n"
|
||||
return os.WriteFile(ConfigPath(), append([]byte(header), body...), 0o644)
|
||||
}
|
||||
49
packages/browseros/tools/bdev/internal/workspace/detect.go
Normal file
49
packages/browseros/tools/bdev/internal/workspace/detect.go
Normal file
@@ -0,0 +1,49 @@
|
||||
package workspace
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func Detect(reg *Registry, cwd string) (Entry, error) {
|
||||
if len(reg.Workspaces) == 0 {
|
||||
return Entry{}, fmt.Errorf("no workspaces registered yet")
|
||||
}
|
||||
abs, err := filepath.Abs(cwd)
|
||||
if err != nil {
|
||||
return Entry{}, err
|
||||
}
|
||||
clean := filepath.Clean(abs)
|
||||
var best Entry
|
||||
bestLen := -1
|
||||
for _, ws := range reg.Workspaces {
|
||||
base := filepath.Clean(ws.Path)
|
||||
if clean == base || strings.HasPrefix(clean, base+string(filepath.Separator)) {
|
||||
if len(base) > bestLen {
|
||||
best = ws
|
||||
bestLen = len(base)
|
||||
}
|
||||
}
|
||||
}
|
||||
if bestLen == -1 {
|
||||
return Entry{}, fmt.Errorf(
|
||||
`not inside a registered workspace; run "bdev list" to inspect workspaces or pass one by name`,
|
||||
)
|
||||
}
|
||||
return best, nil
|
||||
}
|
||||
|
||||
func Resolve(reg *Registry, name string, cwd string, src string) (Entry, error) {
|
||||
if src != "" {
|
||||
path, err := NormalizeWorkspacePath(src)
|
||||
if err != nil {
|
||||
return Entry{}, err
|
||||
}
|
||||
return Entry{Name: filepath.Base(path), Path: path}, nil
|
||||
}
|
||||
if name != "" {
|
||||
return reg.Get(name)
|
||||
}
|
||||
return Detect(reg, cwd)
|
||||
}
|
||||
121
packages/browseros/tools/bdev/internal/workspace/registry.go
Normal file
121
packages/browseros/tools/bdev/internal/workspace/registry.go
Normal file
@@ -0,0 +1,121 @@
|
||||
package workspace
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"time"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
type Entry struct {
|
||||
Name string `yaml:"name" json:"name"`
|
||||
Path string `yaml:"path" json:"path"`
|
||||
AddedAt time.Time `yaml:"added_at,omitempty" json:"added_at,omitempty"`
|
||||
}
|
||||
|
||||
type Registry struct {
|
||||
Version int `yaml:"version" json:"version"`
|
||||
Workspaces []Entry `yaml:"workspaces" json:"workspaces"`
|
||||
}
|
||||
|
||||
func LoadRegistry() (*Registry, error) {
|
||||
data, err := os.ReadFile(RegistryPath())
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return &Registry{Version: 1}, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
var reg Registry
|
||||
if err := yaml.Unmarshal(data, ®); err != nil {
|
||||
return nil, fmt.Errorf("parse registry: %w", err)
|
||||
}
|
||||
if reg.Version == 0 {
|
||||
reg.Version = 1
|
||||
}
|
||||
return ®, nil
|
||||
}
|
||||
|
||||
func SaveRegistry(reg *Registry) error {
|
||||
if reg.Version == 0 {
|
||||
reg.Version = 1
|
||||
}
|
||||
if err := os.MkdirAll(ConfigDir(), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
body, err := yaml.Marshal(reg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
header := "# bdev workspaces\n\n"
|
||||
return os.WriteFile(RegistryPath(), append([]byte(header), body...), 0o644)
|
||||
}
|
||||
|
||||
func NormalizeWorkspacePath(raw string) (string, error) {
|
||||
abs, err := filepath.Abs(raw)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
clean := filepath.Clean(abs)
|
||||
info, err := os.Stat(clean)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if !info.IsDir() {
|
||||
return "", fmt.Errorf("workspace path is not a directory: %s", clean)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(clean, ".git")); err != nil {
|
||||
return "", fmt.Errorf("workspace is not a git checkout: %s", clean)
|
||||
}
|
||||
return clean, nil
|
||||
}
|
||||
|
||||
func (r *Registry) Get(name string) (Entry, error) {
|
||||
for _, ws := range r.Workspaces {
|
||||
if ws.Name == name {
|
||||
return ws, nil
|
||||
}
|
||||
}
|
||||
return Entry{}, fmt.Errorf("workspace %q not found", name)
|
||||
}
|
||||
|
||||
func (r *Registry) Add(name string, path string) (Entry, error) {
|
||||
normalized, err := NormalizeWorkspacePath(path)
|
||||
if err != nil {
|
||||
return Entry{}, err
|
||||
}
|
||||
for _, ws := range r.Workspaces {
|
||||
switch {
|
||||
case ws.Name == name:
|
||||
return Entry{}, fmt.Errorf("workspace %q already exists", name)
|
||||
case ws.Path == normalized:
|
||||
return Entry{}, fmt.Errorf("workspace path already registered as %q", ws.Name)
|
||||
}
|
||||
}
|
||||
entry := Entry{Name: name, Path: normalized, AddedAt: time.Now().UTC()}
|
||||
r.Workspaces = append(r.Workspaces, entry)
|
||||
slices.SortFunc(r.Workspaces, func(a, b Entry) int {
|
||||
if a.Name < b.Name {
|
||||
return -1
|
||||
}
|
||||
if a.Name > b.Name {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
})
|
||||
return entry, nil
|
||||
}
|
||||
|
||||
func (r *Registry) Remove(name string) (Entry, error) {
|
||||
for idx, ws := range r.Workspaces {
|
||||
if ws.Name != name {
|
||||
continue
|
||||
}
|
||||
r.Workspaces = append(r.Workspaces[:idx], r.Workspaces[idx+1:]...)
|
||||
return ws, nil
|
||||
}
|
||||
return Entry{}, fmt.Errorf("workspace %q not found", name)
|
||||
}
|
||||
70
packages/browseros/tools/bdev/internal/workspace/state.go
Normal file
70
packages/browseros/tools/bdev/internal/workspace/state.go
Normal file
@@ -0,0 +1,70 @@
|
||||
package workspace
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
type State struct {
|
||||
Version int `yaml:"version" json:"version"`
|
||||
Workspace string `yaml:"workspace,omitempty" json:"workspace,omitempty"`
|
||||
BaseCommit string `yaml:"base_commit,omitempty" json:"base_commit,omitempty"`
|
||||
LastApplyRev string `yaml:"last_apply_rev,omitempty" json:"last_apply_rev,omitempty"`
|
||||
LastSyncRev string `yaml:"last_sync_rev,omitempty" json:"last_sync_rev,omitempty"`
|
||||
LastExtractRev string `yaml:"last_extract_rev,omitempty" json:"last_extract_rev,omitempty"`
|
||||
PendingStash string `yaml:"pending_stash,omitempty" json:"pending_stash,omitempty"`
|
||||
LastApplyAt time.Time `yaml:"last_apply_at,omitempty" json:"last_apply_at,omitempty"`
|
||||
LastSyncAt time.Time `yaml:"last_sync_at,omitempty" json:"last_sync_at,omitempty"`
|
||||
LastExtractAt time.Time `yaml:"last_extract_at,omitempty" json:"last_extract_at,omitempty"`
|
||||
}
|
||||
|
||||
func StateDir(workspacePath string) string {
|
||||
return filepath.Join(workspacePath, ".bdev")
|
||||
}
|
||||
|
||||
func StatePath(workspacePath string) string {
|
||||
return filepath.Join(StateDir(workspacePath), "state.yaml")
|
||||
}
|
||||
|
||||
func LoadState(workspacePath string) (*State, error) {
|
||||
data, err := os.ReadFile(StatePath(workspacePath))
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return &State{Version: 1, Workspace: workspacePath}, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
var state State
|
||||
if err := yaml.Unmarshal(data, &state); err != nil {
|
||||
return nil, fmt.Errorf("parse state: %w", err)
|
||||
}
|
||||
if state.Version == 0 {
|
||||
state.Version = 1
|
||||
}
|
||||
if state.Workspace == "" {
|
||||
state.Workspace = workspacePath
|
||||
}
|
||||
return &state, nil
|
||||
}
|
||||
|
||||
func SaveState(workspacePath string, state *State) error {
|
||||
if state.Version == 0 {
|
||||
state.Version = 1
|
||||
}
|
||||
if state.Workspace == "" {
|
||||
state.Workspace = workspacePath
|
||||
}
|
||||
if err := os.MkdirAll(StateDir(workspacePath), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
body, err := yaml.Marshal(state)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
header := "# bdev workspace state\n\n"
|
||||
return os.WriteFile(StatePath(workspacePath), append([]byte(header), body...), 0o644)
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package workspace
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestConfigRoundTrip(t *testing.T) {
|
||||
configHome := t.TempDir()
|
||||
t.Setenv("XDG_CONFIG_HOME", configHome)
|
||||
|
||||
cfg := &Config{Version: 1, PatchesRepo: "/tmp/browseros"}
|
||||
if err := SaveConfig(cfg); err != nil {
|
||||
t.Fatalf("SaveConfig: %v", err)
|
||||
}
|
||||
|
||||
loaded, err := LoadConfig()
|
||||
if err != nil {
|
||||
t.Fatalf("LoadConfig: %v", err)
|
||||
}
|
||||
if loaded.PatchesRepo != cfg.PatchesRepo {
|
||||
t.Fatalf("patches repo mismatch: got %q want %q", loaded.PatchesRepo, cfg.PatchesRepo)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegistryDetectsLongestMatchingWorkspace(t *testing.T) {
|
||||
configHome := t.TempDir()
|
||||
t.Setenv("XDG_CONFIG_HOME", configHome)
|
||||
|
||||
root := t.TempDir()
|
||||
parent := filepath.Join(root, "chromium")
|
||||
child := filepath.Join(parent, "src")
|
||||
for _, dir := range []string{parent, child} {
|
||||
if err := os.MkdirAll(filepath.Join(dir, ".git"), 0o755); err != nil {
|
||||
t.Fatalf("mkdir: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
reg := &Registry{Version: 1}
|
||||
if _, err := reg.Add("parent", parent); err != nil {
|
||||
t.Fatalf("add parent: %v", err)
|
||||
}
|
||||
if _, err := reg.Add("child", child); err != nil {
|
||||
t.Fatalf("add child: %v", err)
|
||||
}
|
||||
|
||||
ws, err := Detect(reg, filepath.Join(child, "chrome", "browser"))
|
||||
if err != nil {
|
||||
t.Fatalf("Detect: %v", err)
|
||||
}
|
||||
if ws.Name != "child" {
|
||||
t.Fatalf("expected child workspace, got %q", ws.Name)
|
||||
}
|
||||
}
|
||||
@@ -1,18 +1,7 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"bdev/cmd"
|
||||
)
|
||||
|
||||
var version = "dev"
|
||||
import "github.com/browseros-ai/BrowserOS/packages/browseros/tools/bdev/cmd"
|
||||
|
||||
func main() {
|
||||
cmd.SetVersion(version)
|
||||
if err := cmd.Execute(); err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(2)
|
||||
}
|
||||
cmd.Execute()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user