Compare commits

...

5 Commits

Author SHA1 Message Date
Nikhil Sonti
79484440d6 refactor: rework 0324-bdev_cli_redesign based on feedback 2026-03-26 09:56:27 -07:00
Nikhil Sonti
831aefac98 fix: address PR review comments for 0324-bdev_cli_redesign 2026-03-24 16:42:17 -07:00
Nikhil Sonti
ea08211219 fix: address review comments for 0324-bdev_cli_redesign 2026-03-24 14:09:00 -07:00
Nikhil Sonti
6c9c1dcb6c feat: rebuild bdev as an XDG-backed Cobra CLI 2026-03-24 14:07:49 -07:00
Nikhil Sonti
4c63eeb718 fix: clean-up bdev 2026-03-24 13:05:33 -07:00
82 changed files with 2707 additions and 3954 deletions

View File

@@ -1,5 +1,2 @@
bros
bros-linux-amd64
bros-linux-arm64
bros-darwin-amd64
bros-darwin-arm64
bdev
*.exe

View File

@@ -1,27 +1,18 @@
BINARY := bdev
VERSION := $(shell git describe --tags --always --dirty 2>/dev/null || echo "dev")
LDFLAGS := -ldflags "-X main.version=$(VERSION)"
GOBIN := $(shell go env GOPATH)/bin
.PHONY: build install clean test
.PHONY: build install test clean
build:
go build $(LDFLAGS) -o $(BINARY) .
go build -o $(BINARY) .
install:
go install $(LDFLAGS) .
clean:
rm -f $(BINARY)
install: build
cp $(BINARY) $(GOBIN)/$(BINARY)
codesign --force --sign - $(GOBIN)/$(BINARY)
@echo "Installed $(BINARY) to $(GOBIN)/$(BINARY)"
test:
go test ./...
build-linux:
GOOS=linux GOARCH=amd64 go build $(LDFLAGS) -o $(BINARY)-linux-amd64 .
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)

View File

@@ -0,0 +1,45 @@
# bdev
BrowserOS Chromium patch workflow CLI.
## Install
Requires Go 1.24+.
```sh
cd tools/bdev
make install
```
This builds `bdev`, copies it into your Go bin directory, and codesigns it for local macOS execution.
## Development
```sh
make build
make test
make clean
```
Machine-local checkout/session state lives under `$XDG_CONFIG_HOME/bdev` or `~/.config/bdev`.
## Quick Start
```sh
bdev init --patches-repo /path/to/browseros
bdev apply --all --clean
bdev export --path chrome/browser/foo.cc
```
## Main Commands
- `bdev checkouts`
- `bdev status`
- `bdev sync`
- `bdev rebase`
- `bdev apply --all --clean`
- `bdev conflicts`
- `bdev resolve <path>`
- `bdev continue`
- `bdev export --path <file>`
- `bdev reset --to base|synced`

View File

@@ -0,0 +1,73 @@
package cmd
import (
"fmt"
"bdev/internal/engine"
"bdev/internal/registry"
"bdev/internal/ui"
"github.com/spf13/cobra"
)
var (
applyCheckout string
applyClean bool
applyAll bool
applyTag string
)
var applyCmd = &cobra.Command{
Use: "apply",
Short: "Apply BrowserOS patches into a checkout",
GroupID: "work",
RunE: func(cmd *cobra.Command, args []string) error {
if !applyAll {
return fail("only --all is supported")
}
record, err := resolveCheckout(applyCheckout)
if err != nil {
return err
}
patchCtx, err := loadPatchContext(record)
if err != nil {
return err
}
activity := ui.NewActivity(!jsonOutput)
result, err := engine.ApplyAll(engine.NewContext(record, patchCtx), engine.ApplyAllOpts{
Clean: applyClean,
TagFeature: applyTag,
}, activity)
if err != nil {
return err
}
if result.Session == nil {
record.LastSyncedRev = result.RepoHead
}
record.LastOp = "apply"
reg.Upsert(*record)
if err := registry.Save(reg); err != nil {
return err
}
fmt.Println(ui.Title("bdev apply"))
fmt.Println()
fmt.Printf(" %s %d\n", ui.Label("Applied:"), len(result.Applied))
fmt.Printf(" %s %d\n", ui.Label("Deleted:"), len(result.Deleted))
if result.Session != nil {
fmt.Printf(" %s %d pending\n", ui.Label("Conflicts:"), len(result.Session.Pending))
return fail("%d conflicts remain; run bdev conflicts", len(result.Session.Pending))
}
for _, warning := range result.Warnings {
fmt.Printf(" %s %s\n", ui.Label("Warning:"), ui.Warn(warning))
}
return nil
},
}
func init() {
applyCmd.Flags().StringVar(&applyCheckout, "checkout", "", "checkout name or id")
applyCmd.Flags().BoolVar(&applyAll, "all", false, "apply the full patch set")
applyCmd.Flags().BoolVar(&applyClean, "clean", false, "reset checkout to base before apply")
applyCmd.Flags().StringVar(&applyTag, "tag-feature", "", "optionally add touched files to a feature")
rootCmd.AddCommand(applyCmd)
}

View File

@@ -0,0 +1,66 @@
package cmd
import (
"encoding/json"
"fmt"
"bdev/internal/session"
"bdev/internal/ui"
"github.com/spf13/cobra"
)
var checkoutsCmd = &cobra.Command{
Use: "checkouts",
Short: "List registered Chromium checkouts",
GroupID: "inspect",
RunE: func(cmd *cobra.Command, args []string) error {
type row struct {
ID string `json:"id"`
Name string `json:"name"`
ChromiumRoot string `json:"chromium_root"`
BrowserOSRepo string `json:"browseros_repo"`
LastSyncedRev string `json:"last_synced_rev,omitempty"`
ActiveSession bool `json:"active_session"`
}
rows := []row{}
for _, checkout := range reg.Checkouts {
_, err := session.Load(checkout.ID)
rows = append(rows, row{
ID: checkout.ID,
Name: checkout.Name,
ChromiumRoot: checkout.ChromiumRoot,
BrowserOSRepo: checkout.BrowserOSRepo,
LastSyncedRev: checkout.LastSyncedRev,
ActiveSession: err == nil,
})
}
if jsonOutput {
data, err := json.MarshalIndent(rows, "", " ")
if err != nil {
return err
}
fmt.Println(string(data))
return nil
}
fmt.Println(ui.Title("bdev checkouts"))
fmt.Println()
for _, checkout := range rows {
sessionState := ui.Muted("idle")
if checkout.ActiveSession {
sessionState = ui.Warn("active session")
}
fmt.Printf(" %s %s %s\n", ui.Value(checkout.Name), ui.Muted("("+checkout.ID+")"), sessionState)
fmt.Printf(" %s %s\n", ui.Label("Chromium:"), checkout.ChromiumRoot)
fmt.Printf(" %s %s\n", ui.Label("Repo:"), checkout.BrowserOSRepo)
if checkout.LastSyncedRev != "" {
fmt.Printf(" %s %s\n", ui.Label("Last sync:"), shortRev(checkout.LastSyncedRev))
}
}
return nil
},
}
func init() {
rootCmd.AddCommand(checkoutsCmd)
}

View File

@@ -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
}

View File

@@ -0,0 +1,49 @@
package cmd
import (
"encoding/json"
"fmt"
"bdev/internal/session"
"bdev/internal/ui"
"github.com/spf13/cobra"
)
var conflictsCheckout string
var conflictsCmd = &cobra.Command{
Use: "conflicts",
Short: "Show active conflict session paths",
GroupID: "repair",
RunE: func(cmd *cobra.Command, args []string) error {
record, err := resolveCheckout(conflictsCheckout)
if err != nil {
return err
}
sess, err := session.Load(record.ID)
if err != nil {
return err
}
if jsonOutput {
data, err := json.MarshalIndent(sess, "", " ")
if err != nil {
return err
}
fmt.Println(string(data))
return nil
}
fmt.Println(ui.Title("bdev conflicts"))
fmt.Println()
fmt.Printf(" %s %s\n", ui.Label("Session:"), ui.Value(sess.Kind))
for _, entry := range sess.Pending {
fmt.Printf(" %s %s %s\n", ui.Warn("!"), entry.Path, ui.Muted("("+entry.Stage+")"))
}
return nil
},
}
func init() {
conflictsCmd.Flags().StringVar(&conflictsCheckout, "checkout", "", "checkout name or id")
rootCmd.AddCommand(conflictsCmd)
}

View File

@@ -0,0 +1,39 @@
package cmd
import (
"os"
"path/filepath"
"bdev/internal/git"
"bdev/internal/patchrepo"
"bdev/internal/registry"
)
func resolveCheckout(name string) (*registry.CheckoutRecord, error) {
if name != "" {
return reg.ResolveByName(name)
}
cwd, err := os.Getwd()
if err != nil {
return nil, err
}
root, err := git.Root(cwd)
if err != nil {
return nil, fail("not inside a git checkout and --checkout not provided")
}
return reg.ResolveByRoot(root)
}
func loadPatchContext(record *registry.CheckoutRecord) (*patchrepo.Context, error) {
return patchrepo.Load(record.BrowserOSRepo)
}
func looksLikeChromium(dir string) bool {
markers := []string{"chrome", "base", ".git"}
for _, marker := range markers {
if _, err := os.Stat(filepath.Join(dir, marker)); err != nil {
return false
}
}
return true
}

View File

@@ -0,0 +1,59 @@
package cmd
import (
"fmt"
"bdev/internal/engine"
"bdev/internal/registry"
"bdev/internal/session"
"bdev/internal/ui"
"github.com/spf13/cobra"
)
var continueCheckout string
var continueCmd = &cobra.Command{
Use: "continue",
Short: "Retry pending conflict session patches",
GroupID: "repair",
RunE: func(cmd *cobra.Command, args []string) error {
record, err := resolveCheckout(continueCheckout)
if err != nil {
return err
}
sess, err := session.Load(record.ID)
if err != nil {
return err
}
patchCtx, err := loadPatchContext(record)
if err != nil {
return err
}
activity := ui.NewActivity(!jsonOutput)
result, err := engine.Continue(engine.NewContext(record, patchCtx), activity)
if err != nil {
return err
}
if result.Remaining == 0 {
record.LastSyncedRev = sess.ToRepoRev
}
record.LastOp = "continue"
reg.Upsert(*record)
if err := registry.Save(reg); err != nil {
return err
}
fmt.Println(ui.Title("bdev continue"))
fmt.Println()
fmt.Printf(" %s %d\n", ui.Label("Remaining conflicts:"), result.Remaining)
if result.Remaining > 0 {
return fail("%d conflicts remain", result.Remaining)
}
return nil
},
}
func init() {
continueCmd.Flags().StringVar(&continueCheckout, "checkout", "", "checkout name or id")
rootCmd.AddCommand(continueCmd)
}

View File

@@ -1,114 +0,0 @@
package cmd
import (
"fmt"
"bdev/internal/config"
"bdev/internal/git"
"bdev/internal/patch"
"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)
}
func runDiff(cmd *cobra.Command, args []string) error {
ctx, err := config.LoadContext()
if err != nil {
return err
}
switch diffDirection {
case "push":
return diffPush(ctx)
case "pull":
return diffPull(ctx)
default:
return fmt.Errorf("invalid direction %q — use \"push\" or \"pull\"", diffDirection)
}
}
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
}

View File

@@ -0,0 +1,55 @@
package cmd
import (
"fmt"
"bdev/internal/engine"
"bdev/internal/ui"
"github.com/spf13/cobra"
)
var (
exportCheckout string
exportPaths []string
exportTag string
)
var exportCmd = &cobra.Command{
Use: "export",
Short: "Export checkout changes back into chromium_patches",
GroupID: "work",
RunE: func(cmd *cobra.Command, args []string) error {
record, err := resolveCheckout(exportCheckout)
if err != nil {
return err
}
patchCtx, err := loadPatchContext(record)
if err != nil {
return err
}
activity := ui.NewActivity(!jsonOutput)
result, err := engine.Export(engine.NewContext(record, patchCtx), engine.ExportOpts{
Paths: exportPaths,
TagFeature: exportTag,
}, activity)
if err != nil {
return err
}
fmt.Println(ui.Title("bdev export"))
fmt.Println()
fmt.Printf(" %s %d\n", ui.Label("Updated patches:"), len(result.Updated))
fmt.Printf(" %s %d\n", ui.Label("Removed patches:"), len(result.Removed))
for _, warning := range result.Warnings {
fmt.Printf(" %s %s\n", ui.Label("Warning:"), ui.Warn(warning))
}
return nil
},
}
func init() {
exportCmd.Flags().StringVar(&exportCheckout, "checkout", "", "checkout name or id")
exportCmd.Flags().StringArrayVar(&exportPaths, "path", nil, "specific chromium path(s) to export")
exportCmd.Flags().StringVar(&exportTag, "tag-feature", "", "optionally add touched files to a feature")
rootCmd.AddCommand(exportCmd)
}

View File

@@ -5,111 +5,69 @@ import (
"os"
"path/filepath"
"bdev/internal/config"
"bdev/internal/git"
"bdev/internal/patchrepo"
"bdev/internal/registry"
"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
)
var initCmd = &cobra.Command{
Use: "init",
Short: "Register the current Chromium checkout",
GroupID: "setup",
RunE: func(cmd *cobra.Command, args []string) error {
cwd, err := os.Getwd()
if err != nil {
return err
}
if !looksLikeChromium(cwd) {
return fail("current directory does not look like a Chromium checkout")
}
patchCtx, err := patchrepo.Load(initPatchesRepo)
if err != nil {
return err
}
root, err := git.Root(cwd)
if err != nil {
return err
}
if !git.CommitExists(root, patchCtx.BaseCommit) {
return fail("BASE_COMMIT %s not found in checkout history", patchCtx.BaseCommit)
}
name := initName
if name == "" {
name = filepath.Base(root)
}
record := reg.Upsert(registry.CheckoutRecord{
Name: name,
ChromiumRoot: root,
BrowserOSRepo: patchCtx.BrowserOSRepo,
BaseCommit: patchCtx.BaseCommit,
LastOp: "init",
})
if err := registry.Save(reg); err != nil {
return err
}
fmt.Println(ui.Title("bdev init"))
fmt.Println()
fmt.Printf(" %s %s\n", ui.Label("Checkout:"), ui.Value(record.Name))
fmt.Printf(" %s %s\n", ui.Label("ID:"), ui.Value(record.ID))
fmt.Printf(" %s %s\n", ui.Label("Chromium:"), ui.Value(record.ChromiumRoot))
fmt.Printf(" %s %s\n", ui.Label("BrowserOS repo:"), ui.Value(record.BrowserOSRepo))
fmt.Printf(" %s %s\n", ui.Label("Base commit:"), ui.Value(shortRev(record.BaseCommit)))
return nil
},
}
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")
initCmd.Flags().StringVar(&initPatchesRepo, "patches-repo", ".", "path to BrowserOS repo")
initCmd.Flags().StringVar(&initName, "name", "", "human-friendly checkout name")
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
}

View File

@@ -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
}

View File

@@ -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))))
}
}

View File

@@ -0,0 +1,55 @@
package cmd
import (
"fmt"
"bdev/internal/engine"
"bdev/internal/registry"
"bdev/internal/ui"
"github.com/spf13/cobra"
)
var rebaseCheckout string
var rebaseCmd = &cobra.Command{
Use: "rebase",
Short: "Restack local checkout changes on top of newer patch-repo changes",
GroupID: "work",
RunE: func(cmd *cobra.Command, args []string) error {
record, err := resolveCheckout(rebaseCheckout)
if err != nil {
return err
}
patchCtx, err := loadPatchContext(record)
if err != nil {
return err
}
activity := ui.NewActivity(!jsonOutput)
result, err := engine.Rebase(engine.NewContext(record, patchCtx), activity)
if err != nil {
return err
}
if result.Session == nil && result.RepoHead != "" {
record.LastSyncedRev = result.RepoHead
}
record.LastOp = "rebase"
reg.Upsert(*record)
if err := registry.Save(reg); err != nil {
return err
}
fmt.Println(ui.Title("bdev rebase"))
fmt.Println()
fmt.Printf(" %s %d\n", ui.Label("Updated paths:"), len(result.Updated))
if result.Session != nil {
fmt.Printf(" %s %d pending\n", ui.Label("Conflicts:"), len(result.Session.Pending))
return fail("%d conflicts remain; run bdev conflicts", len(result.Session.Pending))
}
return nil
},
}
func init() {
rebaseCmd.Flags().StringVar(&rebaseCheckout, "checkout", "", "checkout name or id")
rootCmd.AddCommand(rebaseCmd)
}

View File

@@ -0,0 +1,61 @@
package cmd
import (
"fmt"
"bdev/internal/engine"
"bdev/internal/git"
"bdev/internal/registry"
"bdev/internal/ui"
"github.com/spf13/cobra"
)
var (
resetCheckout string
resetTarget string
)
var resetCmd = &cobra.Command{
Use: "reset",
Short: "Reset a checkout to base or to the current synced patch state",
GroupID: "repair",
RunE: func(cmd *cobra.Command, args []string) error {
record, err := resolveCheckout(resetCheckout)
if err != nil {
return err
}
patchCtx, err := loadPatchContext(record)
if err != nil {
return err
}
ctx := engine.NewContext(record, patchCtx)
switch resetTarget {
case "base":
err = engine.ResetToBase(ctx)
case "synced":
_, err = engine.ApplyAll(ctx, engine.ApplyAllOpts{Clean: true}, ui.NewActivity(!jsonOutput))
if err == nil {
record.LastSyncedRev, err = git.HeadRev(record.BrowserOSRepo)
}
default:
return fail("reset target must be base or synced")
}
if err != nil {
return err
}
record.LastOp = "reset"
reg.Upsert(*record)
if err := registry.Save(reg); err != nil {
return err
}
fmt.Println(ui.Success("reset"), resetTarget)
return nil
},
}
func init() {
resetCmd.Flags().StringVar(&resetCheckout, "checkout", "", "checkout name or id")
resetCmd.Flags().StringVar(&resetTarget, "to", "base", "reset target: base or synced")
rootCmd.AddCommand(resetCmd)
}

View File

@@ -0,0 +1,67 @@
package cmd
import (
"fmt"
"slices"
"bdev/internal/registry"
"bdev/internal/session"
"bdev/internal/ui"
"github.com/spf13/cobra"
)
var resolveCheckoutName string
var resolveCmd = &cobra.Command{
Use: "resolve <chromium/path>",
Short: "Mark a conflict path as resolved after manual repair",
GroupID: "repair",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
record, err := resolveCheckout(resolveCheckoutName)
if err != nil {
return err
}
sess, err := session.Load(record.ID)
if err != nil {
return err
}
path := args[0]
next := sess.Pending[:0]
found := false
for _, entry := range sess.Pending {
if entry.Path == path {
found = true
sess.Resolved = append(sess.Resolved, entry.Path)
continue
}
next = append(next, entry)
}
if !found {
return fail("path %s is not pending in the active session", path)
}
slices.Sort(sess.Resolved)
sess.Pending = next
if len(next) == 0 {
if err := session.Delete(record.ID); err != nil {
return err
}
record.LastSyncedRev = sess.ToRepoRev
record.LastOp = "resolve"
reg.Upsert(*record)
if err := registry.Save(reg); err != nil {
return err
}
} else if err := session.Save(sess); err != nil {
return err
}
fmt.Println(ui.Success("resolved"), path)
return nil
},
}
func init() {
resolveCmd.Flags().StringVar(&resolveCheckoutName, "checkout", "", "checkout name or id")
rootCmd.AddCommand(resolveCmd)
}

View File

@@ -1,31 +1,67 @@
package cmd
import (
"fmt"
"strings"
"bdev/internal/config"
"bdev/internal/registry"
"github.com/spf13/cobra"
)
var (
verbose bool
version string
cfg *config.Config
reg *registry.Registry
jsonOutput bool
)
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.",
SilenceUsage: true,
SilenceErrors: true,
}
func init() {
rootCmd.PersistentFlags().BoolVarP(&verbose, "verbose", "v", false, "increase output detail")
}
func SetVersion(v string) {
version = v
rootCmd.Version = v
Use: "bdev",
Short: "BrowserOS patch workflow CLI",
Long: "bdev manages BrowserOS Chromium checkouts, patch sync, restacks, exports, and conflict sessions.",
PersistentPreRunE: func(cmd *cobra.Command, args []string) error {
var err error
cfg, err = config.Load()
if err != nil {
return err
}
reg, err = registry.Load()
return err
},
}
func Execute() error {
return rootCmd.Execute()
}
func init() {
rootCmd.PersistentFlags().BoolVar(&jsonOutput, "json", false, "output machine-readable JSON")
rootCmd.AddGroup(
&cobra.Group{ID: "setup", Title: "Setup:"},
&cobra.Group{ID: "inspect", Title: "Inspect:"},
&cobra.Group{ID: "work", Title: "Workflows:"},
&cobra.Group{ID: "repair", Title: "Repair:"},
)
rootCmd.SetHelpTemplate(helpTemplate)
cobra.AddTemplateFunc("helpHeader", func(value string) string { return value })
cobra.AddTemplateFunc("trim", strings.TrimSpace)
}
const helpTemplate = `{{helpHeader "Usage:"}}
{{.UseLine}}{{if .HasAvailableSubCommands}}
{{helpHeader "Commands:"}}{{range .Groups}}
{{$gid := .ID}}{{.Title}}{{range $.Commands}}{{if and .IsAvailableCommand (eq .GroupID $gid)}}
{{rpad .Name .NamePadding }} {{.Short}}{{end}}{{end}}{{end}}{{end}}{{if .HasAvailableLocalFlags}}
{{helpHeader "Flags:"}}
{{.LocalFlags.FlagUsages | trimTrailingWhitespaces}}{{end}}{{if .HasAvailableInheritedFlags}}
{{helpHeader "Global Flags:"}}
{{.InheritedFlags.FlagUsages | trimTrailingWhitespaces}}{{end}}
`
func fail(format string, args ...any) error {
return fmt.Errorf(format, args...)
}

View File

@@ -4,93 +4,93 @@ import (
"encoding/json"
"fmt"
"bdev/internal/config"
"bdev/internal/engine"
"bdev/internal/git"
"bdev/internal/session"
"bdev/internal/ui"
"github.com/spf13/cobra"
)
var statusCheckout string
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, "", " ")
Use: "status",
Short: "Show status for a registered checkout",
GroupID: "inspect",
RunE: func(cmd *cobra.Command, args []string) error {
record, err := resolveCheckout(statusCheckout)
if err != nil {
return err
}
fmt.Println(string(data))
patchCtx, err := loadPatchContext(record)
if err != nil {
return err
}
localChanges, err := git.DiffNameStatus(record.ChromiumRoot, patchCtx.BaseCommit)
if err != nil {
return err
}
repoHead, err := git.HeadRev(record.BrowserOSRepo)
if err != nil {
return err
}
repoDirty, err := git.IsDirty(record.BrowserOSRepo)
if err != nil {
return err
}
sess, _ := session.Load(record.ID)
payload := map[string]any{
"id": record.ID,
"name": record.Name,
"chromium_root": record.ChromiumRoot,
"browseros_repo": record.BrowserOSRepo,
"base_commit": record.BaseCommit,
"repo_head": repoHead,
"repo_dirty": repoDirty,
"local_changes": len(localChanges),
"last_synced": record.LastSyncedRev,
"active_session": sess != nil,
}
if jsonOutput {
data, err := json.MarshalIndent(payload, "", " ")
if err != nil {
return err
}
fmt.Println(string(data))
return nil
}
fmt.Println(ui.Title("bdev status"))
fmt.Println()
fmt.Printf(" %s %s\n", ui.Label("Checkout:"), ui.Value(record.Name))
fmt.Printf(" %s %s\n", ui.Label("Chromium:"), ui.Value(record.ChromiumRoot))
fmt.Printf(" %s %s\n", ui.Label("BrowserOS repo:"), ui.Value(record.BrowserOSRepo))
fmt.Printf(" %s %s\n", ui.Label("Base commit:"), ui.Value(shortRev(record.BaseCommit)))
fmt.Printf(" %s %s\n", ui.Label("Repo head:"), ui.Value(shortRev(repoHead)))
if patchCtx.ChromiumVersion != "" {
fmt.Printf(" %s %s\n", ui.Label("Chromium version:"), ui.Value(patchCtx.ChromiumVersion))
}
fmt.Printf(" %s %d\n", ui.Label("Local changes:"), len(localChanges))
if record.LastSyncedRev != "" {
fmt.Printf(" %s %s\n", ui.Label("Last sync:"), ui.Value(shortRev(record.LastSyncedRev)))
}
if repoDirty {
fmt.Printf(" %s %s\n", ui.Label("Patch repo state:"), ui.Warn("dirty"))
}
if sess != nil {
fmt.Printf(" %s %s (%d pending)\n", ui.Label("Session:"), ui.Warn(sess.Kind), len(sess.Pending))
}
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)
}
}
func init() {
statusCmd.Flags().StringVar(&statusCheckout, "checkout", "", "checkout name or id")
rootCmd.AddCommand(statusCmd)
}
func shortRev(rev string) string {
if len(rev) <= 12 {
return rev
}
return rev[:12]
}

View File

@@ -0,0 +1,55 @@
package cmd
import (
"fmt"
"bdev/internal/engine"
"bdev/internal/registry"
"bdev/internal/ui"
"github.com/spf13/cobra"
)
var syncCheckout string
var syncCmd = &cobra.Command{
Use: "sync",
Short: "Apply upstream patch-repo changes onto a clean checkout",
GroupID: "work",
RunE: func(cmd *cobra.Command, args []string) error {
record, err := resolveCheckout(syncCheckout)
if err != nil {
return err
}
patchCtx, err := loadPatchContext(record)
if err != nil {
return err
}
activity := ui.NewActivity(!jsonOutput)
result, err := engine.Sync(engine.NewContext(record, patchCtx), activity)
if err != nil {
return err
}
if result.Session == nil && result.RepoHead != "" {
record.LastSyncedRev = result.RepoHead
}
record.LastOp = "sync"
reg.Upsert(*record)
if err := registry.Save(reg); err != nil {
return err
}
fmt.Println(ui.Title("bdev sync"))
fmt.Println()
fmt.Printf(" %s %d\n", ui.Label("Updated paths:"), len(result.Updated))
if result.Session != nil {
fmt.Printf(" %s %d pending\n", ui.Label("Conflicts:"), len(result.Session.Pending))
return fail("%d conflicts remain; run bdev conflicts", len(result.Session.Pending))
}
return nil
},
}
func init() {
syncCmd.Flags().StringVar(&syncCheckout, "checkout", "", "checkout name or id")
rootCmd.AddCommand(syncCmd)
}

View File

@@ -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]
}

View File

@@ -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)
}

View File

@@ -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)
}

View File

@@ -1,7 +1,6 @@
package e2e
import (
"encoding/json"
"os"
"os/exec"
"path/filepath"
@@ -9,176 +8,124 @@ import (
"testing"
)
var bdevBinary string
func TestMain(m *testing.M) {
root, err := os.MkdirTemp("", "bdev-bin-*")
if err != nil {
panic(err)
}
defer os.RemoveAll(root)
bdevBinary = filepath.Join(root, "bdev")
cmd := exec.Command("go", "build", "-o", bdevBinary, ".")
cmd.Dir = ".."
if out, err := cmd.CombinedOutput(); err != nil {
panic(string(out))
}
os.Exit(m.Run())
}
func TestApplyExportAndFeatureTag(t *testing.T) {
env := setupScenario(t)
runBdev(t, env, env.chromium, "init", "--patches-repo", env.repo, "--name", "ch1")
runBdev(t, env, env.chromium, "apply", "--all", "--clean")
assertFileContains(t, filepath.Join(env.chromium, env.path), "upstream-1")
writeFile(t, filepath.Join(env.chromium, env.path), "upstream-2\nline2\n")
runBdev(t, env, env.chromium, "export", "--path", env.path, "--tag-feature", "test-feature")
assertFileContains(t, filepath.Join(env.repo, "chromium_patches", env.path), "upstream-2")
assertFileContains(t, filepath.Join(env.repo, "build", "features.yaml"), "test-feature")
assertFileContains(t, filepath.Join(env.repo, "build", "features.yaml"), env.path)
}
func TestHelpListsGroupedCommands(t *testing.T) {
env := setupScenario(t)
out := runBdev(t, env, env.chromium, "--help")
for _, want := range []string{"Setup:", "Inspect:", "Workflows:", "Repair:", "init", "checkouts", "apply", "reset"} {
if !strings.Contains(out, want) {
t.Fatalf("expected help output to contain %q\n%s", want, out)
}
}
}
func TestRebaseReplaysLocalOverlay(t *testing.T) {
env := setupScenario(t)
runBdev(t, env, env.chromium, "init", "--patches-repo", env.repo, "--name", "ch1")
runBdev(t, env, env.chromium, "apply", "--all", "--clean")
writeFile(t, filepath.Join(env.chromium, env.path), "upstream-1\nlocal-change\n")
updateRepoPatch(t, env.chromium, env.repo, env.baseCommit, env.path, "upstream-2\nline2\n")
runBdev(t, env, env.chromium, "rebase")
assertFileContains(t, filepath.Join(env.chromium, env.path), "upstream-2\nlocal-change\n")
}
type scenario struct {
root string
repo string
chromium string
path string
baseCommit string
patchesRemote string
patchesRepo string
chromiumA string
chromiumB string
trackedPath string
newPath string
xdg 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 {
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"))
repo := filepath.Join(root, "browseros")
chromium := filepath.Join(root, "chromium")
path := filepath.ToSlash(filepath.Join("chrome", "app", "test.txt"))
xdg := filepath.Join(root, "xdg")
runGit(t, root, "init", "--bare", patchesRemote)
setupChromiumRepo(t, chromiumA)
mkdir(t, filepath.Join(chromium, "chrome", "app"))
mkdir(t, filepath.Join(chromium, "base"))
runGit(t, chromium, "init")
configRepo(t, chromium)
writeFile(t, filepath.Join(chromium, path), "base\nline2\n")
writeFile(t, filepath.Join(chromium, "base", ".keep"), "marker\n")
runGit(t, chromium, "add", "-A")
runGit(t, chromium, "commit", "-m", "base")
baseCommit := strings.TrimSpace(runGit(t, chromium, "rev-parse", "HEAD"))
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"))
runGit(t, root, "init", repo)
configRepo(t, repo)
writeFile(t, filepath.Join(repo, "BASE_COMMIT"), baseCommit+"\n")
writeFile(t, filepath.Join(repo, "CHROMIUM_VERSION"), "MAJOR=146\nMINOR=0\nBUILD=7680\nPATCH=31\n")
updateRepoPatch(t, chromium, repo, baseCommit, path, "upstream-1\nline2\n")
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,
return scenario{
root: root,
repo: repo,
chromium: chromium,
path: path,
baseCommit: baseCommit,
xdg: xdg,
}
}
func setupChromiumRepo(t *testing.T, dir string) {
func updateRepoPatch(t *testing.T, chromiumRepo, repo, baseCommit, relPath, content 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)
tmp := filepath.Join(filepath.Dir(repo), "tmp-chromium")
runGit(t, filepath.Dir(repo), "clone", "--quiet", chromiumRepo, tmp)
configRepo(t, tmp)
runGit(t, tmp, "checkout", "--quiet", baseCommit)
writeFile(t, filepath.Join(tmp, relPath), content)
diff := runGit(t, tmp, "diff", "--full-index", baseCommit, "--", relPath)
if strings.TrimSpace(diff) == "" {
t.Fatalf("expected non-empty diff for %s", relPath)
t.Fatalf("expected diff for %s", relPath)
}
return diff
writeFile(t, filepath.Join(repo, "chromium_patches", relPath), diff)
runGit(t, repo, "add", "-A")
runGit(t, repo, "commit", "-m", "update patch")
os.RemoveAll(tmp)
}
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 {
func runBdev(t *testing.T, env scenario, dir string, args ...string) string {
t.Helper()
cmd := exec.Command(bdevBinary, args...)
cmd.Dir = dir
cmd.Env = append(os.Environ(), "XDG_CONFIG_HOME="+env.xdg)
out, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("bdev %v failed: %v\n%s", args, err, string(out))
@@ -197,48 +144,34 @@ func runGit(t *testing.T, dir string, args ...string) string {
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")
runGit(t, dir, "config", "user.email", "bdev@example.com")
runGit(t, dir, "config", "user.name", "bdev")
}
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)
}
mkdir(t, filepath.Dir(path))
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 {
func mkdir(t *testing.T, dir 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)
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatalf("mkdir %s: %v", dir, err)
}
}
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)
data, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read %s: %v", path, err)
}
if !strings.Contains(string(data), want) {
t.Fatalf("expected %s to contain %q\n%s", path, want, string(data))
}
}

View File

@@ -1,27 +1,18 @@
module 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
github.com/fatih/color v1.18.0
github.com/spf13/cobra v1.10.1
golang.org/x/term v0.30.0
gopkg.in/yaml.v3 v3.0.1
)
require (
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect
github.com/charmbracelet/x/ansi v0.8.0 // indirect
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect
github.com/charmbracelet/x/term v0.2.1 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-runewidth v0.0.16 // indirect
github.com/muesli/termenv v0.16.0 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/spf13/pflag v1.0.9 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
golang.org/x/sys v0.30.0 // indirect
golang.org/x/sys v0.31.0 // indirect
)

View File

@@ -1,44 +1,24 @@
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/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=
github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30=
github.com/charmbracelet/x/ansi v0.8.0 h1:9GTq3xq9caJW8ZrBTe0LIe2fvfLR/bYXKTx2llXn7xE=
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/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=
github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM=
github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
github.com/spf13/cobra v1.10.1 h1:lJeBwCfmrnXthfAupyUTzJ/J4Nc1RsHC/mSRU2dll/s=
github.com/spf13/cobra v1.10.1/go.mod h1:7SmJGaTHFVBY0jW4NXGluQoLvhqFQM+6XSKD+P4XaB0=
github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY=
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
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.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
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=
golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik=
golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/term v0.30.0 h1:PQ39fJZ+mfadBm0y5WlL4vlM7Sx1Hgf13sMIY2+QS9Y=
golang.org/x/term v0.30.0/go.mod h1:NYYFdzHoI5wRh/h5tDMdMqCqPJZEuNqVR5xJLd/n67g=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=

View File

@@ -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
}

View File

@@ -1,41 +1,63 @@
package config
import (
"fmt"
"os"
"path/filepath"
"gopkg.in/yaml.v3"
)
type Config struct {
Name string `yaml:"name"`
PatchesRepo string `yaml:"patches_repo"`
DefaultCheckout string `yaml:"default_checkout,omitempty"`
ColorMode string `yaml:"color_mode,omitempty"`
ProgressMode string `yaml:"progress_mode,omitempty"`
}
func ReadConfig(brosDir string) (*Config, error) {
data, err := os.ReadFile(filepath.Join(brosDir, "config.yaml"))
func DefaultConfig() *Config {
return &Config{
ColorMode: "auto",
ProgressMode: "auto",
}
}
func Load() (*Config, error) {
if err := EnsureLayout(); err != nil {
return nil, err
}
path, err := ConfigPath()
if err != nil {
return nil, fmt.Errorf("reading config: %w", err)
return nil, err
}
var cfg Config
if err := yaml.Unmarshal(data, &cfg); err != nil {
return nil, fmt.Errorf("parsing config.yaml: %w", err)
data, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
return DefaultConfig(), nil
}
return nil, err
}
return &cfg, nil
cfg := DefaultConfig()
if err := yaml.Unmarshal(data, cfg); err != nil {
return nil, err
}
if cfg.ColorMode == "" {
cfg.ColorMode = "auto"
}
if cfg.ProgressMode == "" {
cfg.ProgressMode = "auto"
}
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)
func Save(cfg *Config) error {
if err := EnsureLayout(); err != nil {
return err
}
path, err := ConfigPath()
if err != nil {
return err
}
data, err := yaml.Marshal(cfg)
if err != nil {
return fmt.Errorf("marshaling config: %w", err)
return 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
return os.WriteFile(path, data, 0o644)
}

View File

@@ -0,0 +1,69 @@
package config
import (
"os"
"path/filepath"
)
func RootDir() (string, error) {
if xdg := os.Getenv("XDG_CONFIG_HOME"); xdg != "" {
return filepath.Join(xdg, "bdev"), nil
}
home, err := os.UserHomeDir()
if err != nil {
return "", err
}
return filepath.Join(home, ".config", "bdev"), nil
}
func ConfigPath() (string, error) {
root, err := RootDir()
if err != nil {
return "", err
}
return filepath.Join(root, "config.yaml"), nil
}
func RegistryPath() (string, error) {
root, err := RootDir()
if err != nil {
return "", err
}
return filepath.Join(root, "checkouts.yaml"), nil
}
func SessionsDir() (string, error) {
root, err := RootDir()
if err != nil {
return "", err
}
return filepath.Join(root, "sessions"), nil
}
func LogsDir() (string, error) {
root, err := RootDir()
if err != nil {
return "", err
}
return filepath.Join(root, "logs"), nil
}
func EnsureLayout() error {
root, err := RootDir()
if err != nil {
return err
}
dirs := []string{root}
if sessions, err := SessionsDir(); err == nil {
dirs = append(dirs, sessions)
}
if logs, err := LogsDir(); err == nil {
dirs = append(dirs, logs)
}
for _, dir := range dirs {
if err := os.MkdirAll(dir, 0o755); err != nil {
return err
}
}
return nil
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -0,0 +1,105 @@
package engine
import (
"slices"
"bdev/internal/feature"
"bdev/internal/git"
"bdev/internal/patch"
"bdev/internal/session"
"bdev/internal/ui"
)
type ApplyAllOpts struct {
Clean bool
TagFeature string
}
type ApplyResult struct {
Applied []string
Deleted []string
Session *session.Session
RepoHead string
Warnings []string
}
func ApplyAll(ctx *Context, opts ApplyAllOpts, activity *ui.Activity) (*ApplyResult, error) {
if err := requireCleanPatchRepo(ctx); err != nil {
return nil, err
}
done := activity.Start("load patch set")
patchSet, err := patch.ReadCurrentPatchSet(ctx.PatchRepo.BrowserOSRepo)
done(err == nil, "")
if err != nil {
return nil, err
}
if opts.Clean {
done = activity.Start("reset checkout to base")
err = resetAllToBase(ctx)
done(err == nil, "")
if err != nil {
return nil, err
}
}
paths := make([]string, 0, len(patchSet))
for path := range patchSet {
paths = append(paths, path)
}
slices.Sort(paths)
result := &ApplyResult{}
sess := &session.Session{
CheckoutID: ctx.Checkout.ID,
Kind: "apply",
}
head, err := git.HeadRev(ctx.PatchRepo.BrowserOSRepo)
if err != nil {
return nil, err
}
result.RepoHead = head
sess.ToRepoRev = head
for _, path := range paths {
fp := patchSet[path]
switch fp.Op {
case patch.OpDeleted:
if err := resetPathToBase(ctx, path); err != nil {
return nil, err
}
result.Deleted = append(result.Deleted, path)
default:
if err := resetPathToBase(ctx, path); err != nil {
return nil, err
}
detail, err := git.Apply(ctx.Checkout.ChromiumRoot, fp.Content)
if err != nil || detail != "" {
sess.Pending = append(sess.Pending, session.ConflictEntry{
Path: path,
Stage: "apply",
PatchContent: string(fp.Content),
Error: detail,
})
continue
}
result.Applied = append(result.Applied, path)
}
}
if len(sess.Pending) > 0 {
if err := session.Save(sess); err != nil {
return nil, err
}
result.Session = sess
return result, nil
}
if err := session.Delete(ctx.Checkout.ID); err != nil {
return nil, err
}
if opts.TagFeature != "" {
if err := feature.TagFeature(feature.TagFeatureOpts{
BrowserOSRepo: ctx.PatchRepo.BrowserOSRepo,
FeatureName: opts.TagFeature,
Paths: append(append([]string{}, result.Applied...), result.Deleted...),
}); err != nil {
result.Warnings = append(result.Warnings, err.Error())
}
}
return result, nil
}

View File

@@ -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
}

View File

@@ -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)
}
}

View File

@@ -0,0 +1,18 @@
package engine
import (
"bdev/internal/patchrepo"
"bdev/internal/registry"
)
type Context struct {
Checkout *registry.CheckoutRecord
PatchRepo *patchrepo.Context
}
func NewContext(record *registry.CheckoutRecord, patchCtx *patchrepo.Context) *Context {
return &Context{
Checkout: record,
PatchRepo: patchCtx,
}
}

View File

@@ -0,0 +1,50 @@
package engine
import (
"slices"
"bdev/internal/git"
"bdev/internal/session"
"bdev/internal/ui"
)
type ContinueResult struct {
Remaining int
}
func Continue(ctx *Context, activity *ui.Activity) (*ContinueResult, error) {
if err := requireCleanPatchRepo(ctx); err != nil {
return nil, err
}
sess, err := session.Load(ctx.Checkout.ID)
if err != nil {
return nil, err
}
done := activity.Start("retry pending patches")
next := []session.ConflictEntry{}
for _, entry := range sess.Pending {
detail, err := git.Apply(ctx.Checkout.ChromiumRoot, []byte(entry.PatchContent))
if err != nil || detail != "" {
entry.Error = detail
next = append(next, entry)
continue
}
sess.Resolved = append(sess.Resolved, entry.Path)
}
slices.Sort(sess.Resolved)
sess.Pending = next
if len(next) == 0 {
if err := session.Delete(ctx.Checkout.ID); err != nil {
done(false, "")
return nil, err
}
done(true, "")
return &ContinueResult{}, nil
}
if err := session.Save(sess); err != nil {
done(false, "")
return nil, err
}
done(true, "")
return &ContinueResult{Remaining: len(next)}, nil
}

View File

@@ -0,0 +1,7 @@
package engine
import "fmt"
func fail(format string, args ...any) error {
return fmt.Errorf(format, args...)
}

View File

@@ -0,0 +1,111 @@
package engine
import (
"bytes"
"path/filepath"
"slices"
"strings"
"bdev/internal/feature"
"bdev/internal/git"
"bdev/internal/patch"
"bdev/internal/ui"
)
type ExportOpts struct {
Paths []string
TagFeature string
}
type ExportResult struct {
Updated []string
Removed []string
Warnings []string
}
func Export(ctx *Context, opts ExportOpts, activity *ui.Activity) (*ExportResult, error) {
result := &ExportResult{}
status, err := git.DiffNameStatus(ctx.Checkout.ChromiumRoot, ctx.PatchRepo.BaseCommit)
if err != nil {
return nil, err
}
paths := opts.Paths
if len(paths) == 0 {
for path := range status {
paths = append(paths, path)
}
}
slices.Sort(paths)
done := activity.Start("write exported patch files")
for _, path := range paths {
state, ok := status[path]
if !ok {
if err := patch.Remove(ctx.PatchRepo.BrowserOSRepo, path); err != nil {
done(false, "")
return nil, err
}
result.Removed = append(result.Removed, path)
continue
}
switch state {
case "D":
if err := patch.Write(ctx.PatchRepo.BrowserOSRepo, &patch.FilePatch{Path: path, Op: patch.OpDeleted}); err != nil {
done(false, "")
return nil, err
}
result.Updated = append(result.Updated, path)
default:
diff, err := git.DiffFile(ctx.Checkout.ChromiumRoot, ctx.PatchRepo.BaseCommit, path)
if err != nil {
done(false, "")
return nil, err
}
if len(diff) == 0 {
diff, err = git.DiffNoIndex("/dev/null", filepath.Join(ctx.Checkout.ChromiumRoot, path))
if err != nil {
done(false, "")
return nil, err
}
diff = normalizeExportPatch(path, diff)
}
if err := patch.Write(ctx.PatchRepo.BrowserOSRepo, &patch.FilePatch{Path: path, Op: patch.OpPatch, Content: diff}); err != nil {
done(false, "")
return nil, err
}
result.Updated = append(result.Updated, path)
}
}
if opts.TagFeature != "" {
if err := feature.TagFeature(feature.TagFeatureOpts{
BrowserOSRepo: ctx.PatchRepo.BrowserOSRepo,
FeatureName: opts.TagFeature,
Paths: append(append([]string{}, result.Updated...), result.Removed...),
}); err != nil {
result.Warnings = append(result.Warnings, err.Error())
}
}
done(true, "")
return result, nil
}
func normalizeExportPatch(path string, raw []byte) []byte {
if len(bytes.TrimSpace(raw)) == 0 {
return raw
}
lines := strings.Split(string(raw), "\n")
for i, line := range lines {
switch {
case strings.HasPrefix(line, "diff --git "):
lines[i] = "diff --git a/" + path + " b/" + path
case strings.HasPrefix(line, "--- "):
if !strings.Contains(line, "/dev/null") {
lines[i] = "--- a/" + path
}
case strings.HasPrefix(line, "+++ "):
if !strings.Contains(line, "/dev/null") {
lines[i] = "+++ b/" + path
}
}
}
return []byte(strings.Join(lines, "\n"))
}

View File

@@ -0,0 +1,184 @@
package engine
import (
"bytes"
"os"
"path/filepath"
"slices"
"strings"
"bdev/internal/git"
"bdev/internal/patch"
)
func requireCleanPatchRepo(ctx *Context) error {
dirty, err := git.IsDirty(ctx.PatchRepo.BrowserOSRepo)
if err != nil {
return err
}
if dirty {
return fail("BrowserOS patch repo is dirty; commit or stash changes first")
}
return nil
}
func resetPathToBase(ctx *Context, path string) error {
full := filepath.Join(ctx.Checkout.ChromiumRoot, path)
if git.FileExistsInCommit(ctx.Checkout.ChromiumRoot, ctx.PatchRepo.BaseCommit, path) {
return git.CheckoutFiles(ctx.Checkout.ChromiumRoot, ctx.PatchRepo.BaseCommit, []string{path})
}
if err := os.Remove(full); err != nil && !os.IsNotExist(err) {
return err
}
return nil
}
func resetAllToBase(ctx *Context) error {
status, err := git.DiffNameStatus(ctx.Checkout.ChromiumRoot, ctx.PatchRepo.BaseCommit)
if err != nil {
return err
}
paths := make([]string, 0, len(status))
for path := range status {
if git.FileExistsInCommit(ctx.Checkout.ChromiumRoot, ctx.PatchRepo.BaseCommit, path) {
paths = append(paths, path)
continue
}
full := filepath.Join(ctx.Checkout.ChromiumRoot, path)
if err := os.RemoveAll(full); err != nil && !os.IsNotExist(err) {
return err
}
}
slices.Sort(paths)
return git.CheckoutFiles(ctx.Checkout.ChromiumRoot, ctx.PatchRepo.BaseCommit, paths)
}
func intersect(a map[string]string, b []string) []string {
seen := map[string]bool{}
for _, value := range b {
if _, ok := a[value]; ok {
seen[value] = true
}
}
out := make([]string, 0, len(seen))
for value := range seen {
out = append(out, value)
}
slices.Sort(out)
return out
}
func materializeState(baseData []byte, baseExists bool, patchContent []byte, path string) ([]byte, bool, error) {
dir, err := os.MkdirTemp("", "bdev-state-*")
if err != nil {
return nil, false, err
}
defer os.RemoveAll(dir)
if _, err := git.Run(dir, "init", "-q"); err != nil {
return nil, false, err
}
target := filepath.Join(dir, path)
if baseExists {
if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil {
return nil, false, err
}
if err := os.WriteFile(target, baseData, 0o644); err != nil {
return nil, false, err
}
}
if len(patchContent) > 0 {
if _, err := git.Apply(dir, patchContent); err != nil {
return nil, false, err
}
}
data, err := os.ReadFile(target)
if err != nil {
if os.IsNotExist(err) {
return nil, false, nil
}
return nil, false, err
}
return data, true, nil
}
func buildOverlayPatch(path string, from []byte, fromExists bool, to []byte, toExists bool) ([]byte, error) {
dir, err := os.MkdirTemp("", "bdev-overlay-*")
if err != nil {
return nil, err
}
defer os.RemoveAll(dir)
oldRoot := filepath.Join(dir, "old")
newRoot := filepath.Join(dir, "new")
oldFile := filepath.Join(oldRoot, path)
newFile := filepath.Join(newRoot, path)
if fromExists {
if err := os.MkdirAll(filepath.Dir(oldFile), 0o755); err != nil {
return nil, err
}
if err := os.WriteFile(oldFile, from, 0o644); err != nil {
return nil, err
}
}
if toExists {
if err := os.MkdirAll(filepath.Dir(newFile), 0o755); err != nil {
return nil, err
}
if err := os.WriteFile(newFile, to, 0o644); err != nil {
return nil, err
}
}
fromArg := oldFile
if !fromExists {
fromArg = "/dev/null"
}
toArg := newFile
if !toExists {
toArg = "/dev/null"
}
raw, err := git.DiffNoIndexUnified(fromArg, toArg, 0)
if err != nil {
return nil, err
}
if len(bytes.TrimSpace(raw)) == 0 {
return nil, nil
}
lines := strings.Split(string(raw), "\n")
for i, line := range lines {
switch {
case strings.HasPrefix(line, "diff --git "):
lines[i] = "diff --git a/" + path + " b/" + path
case strings.HasPrefix(line, "--- "):
if !strings.Contains(line, "/dev/null") {
lines[i] = "--- a/" + path
}
case strings.HasPrefix(line, "+++ "):
if !strings.Contains(line, "/dev/null") {
lines[i] = "+++ b/" + path
}
}
}
return []byte(strings.Join(lines, "\n")), nil
}
func currentFile(ctx *Context, path string) ([]byte, bool, error) {
full := filepath.Join(ctx.Checkout.ChromiumRoot, path)
data, err := os.ReadFile(full)
if err != nil {
if os.IsNotExist(err) {
return nil, false, nil
}
return nil, false, err
}
return data, true, nil
}
func baseFile(ctx *Context, path string) ([]byte, bool, error) {
return git.ShowFile(ctx.Checkout.ChromiumRoot, ctx.PatchRepo.BaseCommit, path)
}
func oldPatch(ctx *Context, path string) (*patch.FilePatch, bool, error) {
if ctx.Checkout.LastSyncedRev == "" {
return nil, false, nil
}
return patch.ReadPatchAtRevision(ctx.PatchRepo.BrowserOSRepo, ctx.Checkout.LastSyncedRev, path)
}

View File

@@ -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
})
}

View 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)
}

View File

@@ -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
}

View File

@@ -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)
}
}

View File

@@ -0,0 +1,134 @@
package engine
import (
"bdev/internal/git"
"bdev/internal/patch"
"bdev/internal/session"
"bdev/internal/ui"
)
type RebaseResult struct {
Updated []string
Session *session.Session
RepoHead string
}
func Rebase(ctx *Context, activity *ui.Activity) (*RebaseResult, error) {
if ctx.Checkout.LastSyncedRev == "" {
return nil, fail("checkout has no synced revision yet; run bdev apply --all --clean first")
}
if err := requireCleanPatchRepo(ctx); err != nil {
return nil, err
}
head, err := git.HeadRev(ctx.PatchRepo.BrowserOSRepo)
if err != nil {
return nil, err
}
result := &RebaseResult{RepoHead: head}
if head == ctx.Checkout.LastSyncedRev {
return result, nil
}
upstreamPaths, err := git.DiffChangedPathsBetween(ctx.PatchRepo.BrowserOSRepo, ctx.Checkout.LastSyncedRev, head)
if err != nil {
return nil, err
}
localStatus, err := git.DiffNameStatus(ctx.Checkout.ChromiumRoot, ctx.PatchRepo.BaseCommit)
if err != nil {
return nil, err
}
overlap := intersect(localStatus, upstreamPaths)
if len(overlap) == 0 {
syncResult, err := Sync(ctx, activity)
if err != nil {
return nil, err
}
return &RebaseResult{Updated: syncResult.Updated, Session: syncResult.Session, RepoHead: syncResult.RepoHead}, nil
}
overlayPatches := map[string][]byte{}
for _, path := range overlap {
baseData, baseExists, err := baseFile(ctx, path)
if err != nil {
return nil, err
}
oldRepoPatch, ok, err := oldPatch(ctx, path)
if err != nil {
return nil, err
}
oldData := baseData
oldExists := baseExists
if ok {
switch oldRepoPatch.Op {
case patch.OpDeleted:
oldData = nil
oldExists = false
default:
oldData, oldExists, err = materializeState(baseData, baseExists, oldRepoPatch.Content, path)
if err != nil {
return nil, err
}
}
}
currentData, currentExists, err := currentFile(ctx, path)
if err != nil {
return nil, err
}
overlay, err := buildOverlayPatch(path, oldData, oldExists, currentData, currentExists)
if err != nil {
return nil, err
}
if len(overlay) > 0 {
overlayPatches[path] = overlay
}
}
sess := &session.Session{
CheckoutID: ctx.Checkout.ID,
Kind: "rebase",
FromRepoRev: ctx.Checkout.LastSyncedRev,
ToRepoRev: head,
}
upstreamFailed := map[string]bool{}
for _, path := range upstreamPaths {
fp, ok, err := patch.ReadCurrentPatch(ctx.PatchRepo.BrowserOSRepo, path)
if err != nil {
return nil, err
}
if err := resetPathToBase(ctx, path); err != nil {
return nil, err
}
if !ok || fp.Op == patch.OpDeleted {
result.Updated = append(result.Updated, path)
continue
}
detail, err := git.Apply(ctx.Checkout.ChromiumRoot, fp.Content)
if err != nil || detail != "" {
upstreamFailed[path] = true
sess.Pending = append(sess.Pending, session.ConflictEntry{
Path: path, Stage: "sync", PatchContent: string(fp.Content), Error: detail,
})
continue
}
result.Updated = append(result.Updated, path)
}
for path, overlay := range overlayPatches {
if upstreamFailed[path] {
continue
}
detail, err := git.Apply(ctx.Checkout.ChromiumRoot, overlay)
if err != nil || detail != "" {
sess.Pending = append(sess.Pending, session.ConflictEntry{
Path: path, Stage: "overlay", PatchContent: string(overlay), Error: detail,
})
}
}
if len(sess.Pending) > 0 {
if err := session.Save(sess); err != nil {
return nil, err
}
result.Session = sess
return result, nil
}
if err := session.Delete(ctx.Checkout.ID); err != nil {
return nil, err
}
return result, nil
}

View File

@@ -0,0 +1,12 @@
package engine
import (
"bdev/internal/session"
)
func ResetToBase(ctx *Context) error {
if err := resetAllToBase(ctx); err != nil {
return err
}
return session.Delete(ctx.Checkout.ID)
}

View File

@@ -1,62 +0,0 @@
package engine
import (
"fmt"
"bdev/internal/config"
"bdev/internal/git"
"bdev/internal/patch"
)
type StatusResult struct {
CheckoutName string
BaseCommit string
ChromiumVersion string
PatchesRepo string
Ahead int
Behind int
Synced int
AheadFiles []string
BehindFiles []string
SyncedFiles []string
}
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)
if err != nil {
return nil, fmt.Errorf("status: reading repo patches: %w", err)
}
// Read local state (working tree vs BASE)
diffOutput, err := git.DiffFull(ctx.ChromiumDir, ctx.BaseCommit)
if err != nil {
return nil, fmt.Errorf("status: reading local diffs: %w", err)
}
localPatchSet, err := patch.ParseUnifiedDiff(diffOutput)
if err != nil {
return nil, fmt.Errorf("status: parsing local diffs: %w", 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
}
return result, nil
}

View File

@@ -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)
}
}

View File

@@ -0,0 +1,89 @@
package engine
import (
"bdev/internal/git"
"bdev/internal/patch"
"bdev/internal/session"
"bdev/internal/ui"
)
type SyncResult struct {
Updated []string
Session *session.Session
RepoHead string
}
func Sync(ctx *Context, activity *ui.Activity) (*SyncResult, error) {
if ctx.Checkout.LastSyncedRev == "" {
applyResult, err := ApplyAll(ctx, ApplyAllOpts{Clean: true}, activity)
if err != nil {
return nil, err
}
return &SyncResult{
Updated: applyResult.Applied,
Session: applyResult.Session,
RepoHead: applyResult.RepoHead,
}, nil
}
if err := requireCleanPatchRepo(ctx); err != nil {
return nil, err
}
head, err := git.HeadRev(ctx.PatchRepo.BrowserOSRepo)
if err != nil {
return nil, err
}
result := &SyncResult{RepoHead: head}
if head == ctx.Checkout.LastSyncedRev {
return result, nil
}
changedPaths, err := git.DiffChangedPathsBetween(ctx.PatchRepo.BrowserOSRepo, ctx.Checkout.LastSyncedRev, head)
if err != nil {
return nil, err
}
localStatus, err := git.DiffNameStatus(ctx.Checkout.ChromiumRoot, ctx.PatchRepo.BaseCommit)
if err != nil {
return nil, err
}
overlap := intersect(localStatus, changedPaths)
if len(overlap) > 0 {
return nil, fail("local changes overlap upstream patch changes; run bdev rebase")
}
sess := &session.Session{
CheckoutID: ctx.Checkout.ID,
Kind: "sync",
FromRepoRev: ctx.Checkout.LastSyncedRev,
ToRepoRev: head,
}
for _, path := range changedPaths {
fp, ok, err := patch.ReadCurrentPatch(ctx.PatchRepo.BrowserOSRepo, path)
if err != nil {
return nil, err
}
if err := resetPathToBase(ctx, path); err != nil {
return nil, err
}
if !ok || fp.Op == patch.OpDeleted {
result.Updated = append(result.Updated, path)
continue
}
detail, err := git.Apply(ctx.Checkout.ChromiumRoot, fp.Content)
if err != nil || detail != "" {
sess.Pending = append(sess.Pending, session.ConflictEntry{
Path: path, Stage: "sync", PatchContent: string(fp.Content), Error: detail,
})
continue
}
result.Updated = append(result.Updated, path)
}
if len(sess.Pending) > 0 {
if err := session.Save(sess); err != nil {
return nil, err
}
result.Session = sess
return result, nil
}
if err := session.Delete(ctx.Checkout.ID); err != nil {
return nil, err
}
return result, nil
}

View File

@@ -0,0 +1,74 @@
package feature
import (
"os"
"path/filepath"
"slices"
"strings"
"gopkg.in/yaml.v3"
)
type featureDoc struct {
Version string `yaml:"version,omitempty"`
Features map[string]*featureItem `yaml:"features,omitempty"`
}
type featureItem struct {
Description string `yaml:"description,omitempty"`
Files []string `yaml:"files,omitempty"`
}
type TagFeatureOpts struct {
BrowserOSRepo string
FeatureName string
Paths []string
}
func TagFeature(opts TagFeatureOpts) error {
path := filepath.Join(opts.BrowserOSRepo, "build", "features.yaml")
doc := featureDoc{
Version: "1.0",
Features: map[string]*featureItem{},
}
data, err := os.ReadFile(path)
if err != nil && !os.IsNotExist(err) {
return err
}
if err == nil {
if err := yaml.Unmarshal(data, &doc); err != nil {
return err
}
}
if doc.Features == nil {
doc.Features = map[string]*featureItem{}
}
item, ok := doc.Features[opts.FeatureName]
if !ok {
item = &featureItem{Description: "feat: " + opts.FeatureName}
doc.Features[opts.FeatureName] = item
}
existing := map[string]bool{}
for _, value := range item.Files {
existing[value] = true
}
for _, value := range opts.Paths {
value = strings.TrimSpace(value)
if value != "" {
existing[value] = true
}
}
item.Files = item.Files[:0]
for value := range existing {
item.Files = append(item.Files, value)
}
slices.Sort(item.Files)
data, err = yaml.Marshal(&doc)
if err != nil {
return err
}
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return err
}
return os.WriteFile(path, data, 0o644)
}

View File

@@ -0,0 +1,52 @@
package feature
import (
"os"
"path/filepath"
"strings"
"testing"
)
func TestTagFeatureCreatesAndUpdatesFeature(t *testing.T) {
root := t.TempDir()
if err := TagFeature(TagFeatureOpts{
BrowserOSRepo: root,
FeatureName: "server",
Paths: []string{"chrome/foo.cc", "chrome/bar.cc", "chrome/foo.cc"},
}); err != nil {
t.Fatalf("TagFeature create: %v", err)
}
if err := TagFeature(TagFeatureOpts{
BrowserOSRepo: root,
FeatureName: "server",
Paths: []string{"chrome/baz.cc"},
}); err != nil {
t.Fatalf("TagFeature update: %v", err)
}
data, err := os.ReadFile(filepath.Join(root, "build", "features.yaml"))
if err != nil {
t.Fatalf("read features.yaml: %v", err)
}
text := string(data)
for _, want := range []string{"server:", "chrome/foo.cc", "chrome/bar.cc", "chrome/baz.cc"} {
if !strings.Contains(text, want) {
t.Fatalf("expected features.yaml to contain %q\n%s", want, text)
}
}
}
func TestTagFeatureReturnsReadErrors(t *testing.T) {
root := t.TempDir()
path := filepath.Join(root, "build", "features.yaml")
if err := os.MkdirAll(path, 0o755); err != nil {
t.Fatalf("mkdir features.yaml dir: %v", err)
}
err := TagFeature(TagFeatureOpts{
BrowserOSRepo: root,
FeatureName: "server",
Paths: []string{"chrome/foo.cc"},
})
if err == nil {
t.Fatal("expected TagFeature to fail when features.yaml is unreadable")
}
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -1,179 +1,97 @@
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) {
func DiffNameStatus(dir, base string) (map[string]string, error) {
out, err := Run(dir, "diff", "--name-status", "-M", base)
if err != nil {
return nil, fmt.Errorf("diff --name-status %s: %w", base, err)
return nil, err
}
result := make(map[string]patch.FileOp)
for _, line := range strings.Split(out, "\n") {
result := map[string]string{}
parseNameStatus(result, out)
untracked, err := Run(dir, "ls-files", "--others", "--exclude-standard")
if err != nil {
return nil, err
}
for _, line := range strings.Split(untracked, "\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
if line != "" {
result[line] = "A"
}
}
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
func DiffFile(dir, base, path string) ([]byte, error) {
return RunBytes(dir, "diff", "--full-index", "-M", base, "--", path)
}
// 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
func DiffFiles(dir, base string, paths []string) ([]byte, error) {
args := []string{"diff", "--full-index", "-M", base, "--"}
args = append(args, paths...)
return RunBytes(dir, args...)
}
// 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
func DiffNoIndex(oldPath, newPath string) ([]byte, error) {
return RunExitCodeOneOK("", "diff", "--no-index", "--full-index", oldPath, newPath)
}
// 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())
func DiffNoIndexUnified(oldPath, newPath string, unified int) ([]byte, error) {
return RunExitCodeOneOK("", "diff", "--no-index", "--full-index", fmt.Sprintf("--unified=%d", unified), oldPath, newPath)
}
// 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")
func DiffChangedPathsBetween(dir, fromRev, toRev string) ([]string, error) {
out, err := Run(dir, "diff", "--name-status", "--find-renames", fmt.Sprintf("%s..%s", fromRev, toRev), "--", "chromium_patches")
if err != nil {
return nil, fmt.Errorf("ls-files --others: %w", err)
return nil, 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)
seen := 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
for _, field := range fields[1:] {
field = strings.TrimSpace(field)
if strings.HasPrefix(field, "chromium_patches/") {
field = strings.TrimPrefix(field, "chromium_patches/")
}
field = strings.TrimSuffix(field, ".deleted")
field = strings.TrimSuffix(field, ".binary")
field = strings.TrimSuffix(field, ".rename")
if field != "" {
seen[field] = true
}
}
}
paths := make([]string, 0, len(seen))
for p := range seen {
paths = append(paths, p)
for path := range seen {
paths = append(paths, path)
}
sort.Strings(paths)
return paths, nil
}
func parseNameStatus(result map[string]string, out string) {
for _, line := range strings.Split(out, "\n") {
line = strings.TrimSpace(line)
if line == "" {
continue
}
fields := strings.Split(line, "\t")
if len(fields) < 2 {
continue
}
status := string(fields[0][0])
path := fields[len(fields)-1]
result[path] = status
}
}

View File

@@ -3,39 +3,56 @@ package git
import (
"bytes"
"context"
"errors"
"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
if err != nil {
return "", err
}
return strings.TrimSpace(string(out)), nil
}
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)
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
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)
if stderr.Len() > 0 {
return nil, fmt.Errorf("git %s: %w: %s", strings.Join(args, " "), err, strings.TrimSpace(stderr.String()))
}
return nil, fmt.Errorf("git %s: %w\n%s", args[0], err, stderr.String())
return nil, fmt.Errorf("git %s: %w", strings.Join(args, " "), err)
}
return stdout.Bytes(), nil
}
func RunExitCodeOneOK(dir string, args ...string) ([]byte, error) {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
defer cancel()
cmd := exec.CommandContext(ctx, "git", args...)
cmd.Dir = dir
var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr
err := cmd.Run()
if err == nil {
return stdout.Bytes(), nil
}
var exitErr *exec.ExitError
if errors.As(err, &exitErr) && exitErr.ExitCode() == 1 {
return stdout.Bytes(), nil
}
if stderr.Len() > 0 {
return nil, fmt.Errorf("git %s: %w: %s", strings.Join(args, " "), err, strings.TrimSpace(stderr.String()))
}
return nil, fmt.Errorf("git %s: %w", strings.Join(args, " "), err)
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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)
}
}

View File

@@ -0,0 +1,46 @@
package git
import (
"fmt"
"strings"
)
func Root(dir string) (string, error) {
return Run(dir, "rev-parse", "--show-toplevel")
}
func HeadRev(dir string) (string, error) {
return Run(dir, "rev-parse", "HEAD")
}
func CommitExists(dir, ref string) bool {
_, err := Run(dir, "cat-file", "-e", ref+"^{commit}")
return err == nil
}
func IsDirty(dir string) (bool, error) {
out, err := Run(dir, "status", "--porcelain")
if err != nil {
return false, err
}
return strings.TrimSpace(out) != "", nil
}
func FileExistsInCommit(dir, ref, path string) bool {
_, err := Run(dir, "cat-file", "-e", ref+":"+path)
return err == nil
}
func ShowFile(dir, ref, path string) ([]byte, bool, error) {
out, err := RunBytes(dir, "show", fmt.Sprintf("%s:%s", ref, path))
if err != nil {
if strings.Contains(err.Error(), "exists on disk, but not in") ||
strings.Contains(err.Error(), "does not exist in") ||
strings.Contains(err.Error(), "path '"+path+"' does not exist") ||
strings.Contains(err.Error(), "path \""+path+"\" does not exist") {
return nil, false, nil
}
return nil, false, err
}
return out, true, nil
}

View File

@@ -0,0 +1,50 @@
package git
import (
"os"
"os/exec"
"path/filepath"
"testing"
)
func TestShowFileReturnsErrorForInvalidRef(t *testing.T) {
dir := t.TempDir()
runGit(t, dir, "init")
configRepo(t, dir)
writeFile(t, dir, "test.txt", "base\n")
runGit(t, dir, "add", "test.txt")
runGit(t, dir, "commit", "-m", "base")
_, _, err := ShowFile(dir, "missing-ref", "test.txt")
if err == nil {
t.Fatal("expected invalid ref error")
}
}
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 configRepo(t *testing.T, dir string) {
t.Helper()
runGit(t, dir, "config", "user.email", "bdev@example.com")
runGit(t, dir, "config", "user.name", "bdev")
}
func writeFile(t *testing.T, dir, path, content string) {
t.Helper()
full := filepath.Join(dir, path)
if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil {
t.Fatalf("mkdir for %s: %v", path, err)
}
if err := os.WriteFile(full, []byte(content), 0o644); err != nil {
t.Fatalf("write %s: %v", path, err)
}
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -0,0 +1,59 @@
package git
import (
"bytes"
"context"
"fmt"
"os/exec"
"time"
)
func CheckoutFiles(dir, ref string, paths []string) error {
if len(paths) == 0 {
return nil
}
args := []string{"checkout", ref, "--"}
args = append(args, paths...)
_, err := Run(dir, args...)
return err
}
func Apply(dir string, patchContent []byte) (string, error) {
if err := runApply(dir, patchContent, "--ignore-whitespace", "--whitespace=nowarn", "-p1"); err == nil {
return "", nil
}
if err := runApply(dir, patchContent, "--ignore-whitespace", "--whitespace=nowarn", "-p1", "--3way"); err == nil {
return "", nil
}
detail, err := applyWithStderr(dir, patchContent, "--reject", "--ignore-whitespace", "--whitespace=nowarn", "-p1")
if err != nil {
return detail, err
}
return "patch applied with rejects", nil
}
func runApply(dir string, patchContent []byte, flags ...string) error {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
defer cancel()
args := append([]string{"apply"}, flags...)
cmd := exec.CommandContext(ctx, "git", args...)
cmd.Dir = dir
cmd.Stdin = bytes.NewReader(patchContent)
return cmd.Run()
}
func applyWithStderr(dir string, patchContent []byte, flags ...string) (string, error) {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
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
err := cmd.Run()
if err == nil {
return "", nil
}
return stderr.String(), fmt.Errorf("git apply failed: %w", err)
}

View File

@@ -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"
}

View File

@@ -1,66 +0,0 @@
package patch
import (
"bytes"
"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)
}
}
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 ") {
continue
}
normalized = append(normalized, strings.TrimRight(line, " \t"))
}
return []byte(strings.Join(normalized, "\n"))
}

View File

@@ -6,100 +6,85 @@ import (
"strings"
)
var diffHeaderPrefix = []byte("diff --git ")
var diffPrefix = []byte("diff --git ")
// ParseUnifiedDiff parses a full `git diff` output into individual file patches.
func ParseUnifiedDiff(raw []byte) (*PatchSet, error) {
ps := NewPatchSet("")
chunks := splitDiffChunks(raw)
for _, chunk := range chunks {
fp, err := parseSingleDiff(chunk)
func ParseUnifiedDiff(raw []byte) (map[string]*FilePatch, error) {
parts := splitChunks(raw)
patches := map[string]*FilePatch{}
for _, part := range parts {
fp, err := parseChunk(part)
if err != nil {
return nil, fmt.Errorf("parsing diff chunk: %w", err)
return nil, err
}
if fp != nil {
ps.Patches[fp.Path] = fp
patches[fp.Path] = fp
}
}
return ps, nil
return patches, nil
}
func splitDiffChunks(raw []byte) [][]byte {
var chunks [][]byte
lines := bytes.Split(raw, []byte("\n"))
func ParsePath(raw []byte) (string, error) {
fp, err := parseChunk(raw)
if err != nil {
return "", err
}
if fp == nil {
return "", fmt.Errorf("empty patch")
}
return fp.Path, nil
}
var current [][]byte
func splitChunks(raw []byte) [][]byte {
lines := bytes.Split(raw, []byte("\n"))
chunks := [][]byte{}
current := [][]byte{}
for _, line := range lines {
if bytes.HasPrefix(line, diffHeaderPrefix) {
if bytes.HasPrefix(line, diffPrefix) {
if len(current) > 0 {
chunks = append(chunks, bytes.Join(current, []byte("\n")))
}
current = [][]byte{line}
} else if len(current) > 0 {
continue
}
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 {
func parseChunk(chunk []byte) (*FilePatch, error) {
if len(bytes.TrimSpace(chunk)) == 0 {
return nil, nil
}
fp := &FilePatch{
Op: OpModified,
Content: chunk,
}
// Parse the diff --git a/... b/... header
lines := strings.Split(string(chunk), "\n")
header := lines[0]
if !strings.HasPrefix(header, "diff --git ") {
return nil, fmt.Errorf("unexpected header: %s", header)
return nil, fmt.Errorf("unexpected patch header")
}
// Extract b/ path from the header
parts := strings.SplitN(header, " b/", 2)
if len(parts) == 2 {
fp.Path = parts[1]
if len(parts) != 2 {
return nil, fmt.Errorf("cannot parse patch header")
}
fp := &FilePatch{
Path: parts[1],
Op: OpPatch,
Content: chunk,
}
// Scan header lines for operation type
for _, line := range lines[1:] {
if strings.HasPrefix(line, "diff --git ") || strings.HasPrefix(line, "@@") {
if strings.HasPrefix(line, "@@") {
break
}
switch {
case strings.HasPrefix(line, "new file mode"):
fp.Op = OpAdded
case strings.HasPrefix(line, "deleted file mode"):
if 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 "):
break
}
if 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
}
}
if fp.Path == "" {
return nil, nil
}
return fp, nil
}

View File

@@ -0,0 +1,29 @@
package patch
import "testing"
func TestParseUnifiedDiffParsesPathAndNewFile(t *testing.T) {
raw := []byte(`diff --git a/foo.txt b/foo.txt
new file mode 100644
index 0000000..1111111 100644
--- /dev/null
+++ b/foo.txt
@@ -0,0 +1 @@
+hello
`)
patches, err := ParseUnifiedDiff(raw)
if err != nil {
t.Fatalf("ParseUnifiedDiff: %v", err)
}
fp := patches["foo.txt"]
if fp == nil {
t.Fatalf("expected foo.txt patch")
}
if fp.Path != "foo.txt" {
t.Fatalf("unexpected path %q", fp.Path)
}
if fp.Op != OpPatch {
t.Fatalf("unexpected op %q", fp.Op)
}
}

View File

@@ -1,159 +1,90 @@
package patch
import (
"context"
"os"
"path/filepath"
"runtime"
"sort"
"strings"
"sync"
"golang.org/x/sync/errgroup"
"bdev/internal/git"
)
// ReadPatchSet reads all patches from the chromium_patches/ directory.
func ReadPatchSet(patchesDir string) (*PatchSet, error) {
ps := NewPatchSet("")
// Collect file paths
var filePaths []string
func ReadCurrentPatchSet(browserOSRepo string) (map[string]*FilePatch, error) {
patchesDir := filepath.Join(browserOSRepo, "chromium_patches")
patches := map[string]*FilePatch{}
err := filepath.Walk(patchesDir, func(path string, info os.FileInfo, err error) error {
if err != nil || info.IsDir() {
return err
}
rel, err := filepath.Rel(patchesDir, path)
if err != nil {
return nil
return err
}
if !info.IsDir() {
filePaths = append(filePaths, path)
fp, err := readFile(path, rel)
if err != nil {
return err
}
patches[fp.Path] = fp
return nil
})
if err != nil {
return nil, err
}
return patches, nil
}
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
})
func ReadCurrentPatch(browserOSRepo, path string) (*FilePatch, bool, error) {
patchesDir := filepath.Join(browserOSRepo, "chromium_patches")
for _, rel := range []string{path + ".deleted", path} {
full := filepath.Join(patchesDir, rel)
if _, err := os.Stat(full); err == nil {
fp, err := readFile(full, rel)
return fp, true, err
}
}
return nil, false, nil
}
if err := g.Wait(); err != nil {
func ReadPatchAtRevision(browserOSRepo, rev, path string) (*FilePatch, bool, error) {
for _, rel := range []string{path + ".deleted", path} {
content, ok, err := git.ShowFile(browserOSRepo, rev, filepath.ToSlash(filepath.Join("chromium_patches", rel)))
if err != nil {
return nil, false, err
}
if !ok {
continue
}
fp := &FilePatch{Path: path, Op: OpPatch, Content: content}
if strings.HasSuffix(rel, ".deleted") {
fp.Op = OpDeleted
fp.Content = nil
}
return fp, true, nil
}
return nil, false, nil
}
func ListPatchPaths(browserOSRepo string) ([]string, error) {
patches, err := ReadCurrentPatchSet(browserOSRepo)
if err != nil {
return nil, err
}
return ps, nil
paths := make([]string, 0, len(patches))
for path := range patches {
paths = append(paths, path)
}
sort.Strings(paths)
return paths, 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
}
func readFile(full, rel string) (*FilePatch, error) {
data, err := os.ReadFile(full)
if err != nil {
return nil, err
}
path := strings.TrimSuffix(rel, ".deleted")
if strings.HasSuffix(rel, ".deleted") {
return &FilePatch{Path: path, Op: OpDeleted}, nil
}
return &FilePatch{Path: path, Op: OpPatch, Content: data}, nil
}

View File

@@ -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")
}
}

View File

@@ -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])
}
}
}

View File

@@ -1,79 +1,14 @@
package patch
type FileOp int
type Op string
const (
OpModified FileOp = iota
OpAdded
OpDeleted
OpRenamed
OpBinary
OpPatch Op = "patch"
OpDeleted Op = "deleted"
)
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 FilePatch struct {
Path string
Op FileOp
Content []byte
OldPath string // for renames
Similarity int // for renames
IsBinary bool
}
type PatchSet struct {
Base string
Patches map[string]*FilePatch // keyed by chromium path
}
func NewPatchSet(base string) *PatchSet {
return &PatchSet{
Base: base,
Patches: make(map[string]*FilePatch),
}
}
type PushResult struct {
Modified []string
Added []string
Deleted []string
Stale []string
Unchanged []string
}
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
Path string
Op Op
Content []byte
}

View File

@@ -1,119 +1,40 @@
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)
})
func Write(browserOSRepo string, fp *FilePatch) error {
root := filepath.Join(browserOSRepo, "chromium_patches")
if err := cleanup(root, fp.Path); err != nil {
return err
}
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 {
if fp.Op == OpDeleted {
target := filepath.Join(root, fp.Path+".deleted")
if err := os.MkdirAll(filepath.Dir(target), 0o755); 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)
return os.WriteFile(target, []byte("deleted: "+fp.Path+"\n"), 0o644)
}
target := filepath.Join(root, fp.Path)
if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil {
return err
}
return os.WriteFile(target, fp.Content, 0o644)
}
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 Remove(browserOSRepo, path string) error {
root := filepath.Join(browserOSRepo, "chromium_patches")
return cleanup(root, path)
}
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)
func cleanup(root, path string) error {
for _, suffix := range []string{"", ".deleted", ".binary", ".rename"} {
full := filepath.Join(root, path+suffix)
if err := os.Remove(full); err != nil && !os.IsNotExist(err) {
return fmt.Errorf("removing stale artifact %s: %w", rel, err)
return err
}
}
return nil
}

View File

@@ -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)
}
}
}

View File

@@ -0,0 +1,74 @@
package patchrepo
import (
"fmt"
"os"
"path/filepath"
"strings"
)
type Context struct {
BrowserOSRepo string
PatchesDir string
BaseCommit string
ChromiumVersion string
}
func Load(repo string) (*Context, error) {
repo, err := filepath.Abs(repo)
if err != nil {
return nil, err
}
patchesDir := filepath.Join(repo, "chromium_patches")
if _, err := os.Stat(patchesDir); err != nil {
return nil, fmt.Errorf("chromium_patches/ not found in %s", repo)
}
baseCommit, err := readTrimmed(filepath.Join(repo, "BASE_COMMIT"))
if err != nil {
return nil, fmt.Errorf("reading BASE_COMMIT: %w", err)
}
version, err := readVersion(filepath.Join(repo, "CHROMIUM_VERSION"))
if err != nil {
return nil, err
}
return &Context{
BrowserOSRepo: repo,
PatchesDir: patchesDir,
BaseCommit: baseCommit,
ChromiumVersion: version,
}, nil
}
func readTrimmed(path string) (string, error) {
data, err := os.ReadFile(path)
if err != nil {
return "", err
}
return strings.TrimSpace(string(data)), nil
}
func readVersion(path string) (string, error) {
data, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
return "", nil
}
return "", err
}
vars := 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 {
continue
}
vars[strings.TrimSpace(parts[0])] = strings.TrimSpace(parts[1])
}
if vars["MAJOR"] == "" {
return "", nil
}
return fmt.Sprintf("%s.%s.%s.%s", vars["MAJOR"], vars["MINOR"], vars["BUILD"], vars["PATCH"]), nil
}

View File

@@ -0,0 +1,154 @@
package registry
import (
"crypto/sha1"
"encoding/hex"
"errors"
"os"
"path/filepath"
"slices"
"strings"
"time"
"bdev/internal/config"
"gopkg.in/yaml.v3"
)
type CheckoutRecord struct {
ID string `yaml:"id"`
Name string `yaml:"name"`
ChromiumRoot string `yaml:"chromium_root"`
BrowserOSRepo string `yaml:"browseros_repo"`
BaseCommit string `yaml:"base_commit"`
LastSyncedRev string `yaml:"last_synced_rev,omitempty"`
LastOp string `yaml:"last_op,omitempty"`
UpdatedAt time.Time `yaml:"updated_at"`
}
type Registry struct {
Checkouts []CheckoutRecord `yaml:"checkouts"`
}
func Load() (*Registry, error) {
if err := config.EnsureLayout(); err != nil {
return nil, err
}
path, err := config.RegistryPath()
if err != nil {
return nil, err
}
data, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
return &Registry{}, nil
}
return nil, err
}
var reg Registry
if err := yaml.Unmarshal(data, &reg); err != nil {
return nil, err
}
return &reg, nil
}
func Save(reg *Registry) error {
if err := config.EnsureLayout(); err != nil {
return err
}
path, err := config.RegistryPath()
if err != nil {
return err
}
data, err := yaml.Marshal(reg)
if err != nil {
return err
}
return os.WriteFile(path, data, 0o644)
}
func (r *Registry) Upsert(record CheckoutRecord) CheckoutRecord {
record.ChromiumRoot = filepath.Clean(record.ChromiumRoot)
record.BrowserOSRepo = filepath.Clean(record.BrowserOSRepo)
record.UpdatedAt = time.Now()
if record.ID == "" {
record.ID = newID(record.Name, record.ChromiumRoot)
}
for i := range r.Checkouts {
if r.Checkouts[i].ChromiumRoot == record.ChromiumRoot || r.Checkouts[i].ID == record.ID {
r.Checkouts[i] = record
slices.SortFunc(r.Checkouts, compare)
return record
}
}
r.Checkouts = append(r.Checkouts, record)
slices.SortFunc(r.Checkouts, compare)
return record
}
func (r *Registry) ResolveByName(name string) (*CheckoutRecord, error) {
name = strings.TrimSpace(name)
if name == "" {
return nil, errors.New("empty checkout name")
}
for i := range r.Checkouts {
if r.Checkouts[i].Name == name || r.Checkouts[i].ID == name {
return &r.Checkouts[i], nil
}
}
return nil, errors.New("checkout not found")
}
func (r *Registry) ResolveByRoot(root string) (*CheckoutRecord, error) {
root = filepath.Clean(root)
for i := range r.Checkouts {
if r.Checkouts[i].ChromiumRoot == root {
return &r.Checkouts[i], nil
}
}
return nil, errors.New("checkout not registered")
}
func (r *Registry) RemoveByID(id string) bool {
for i := range r.Checkouts {
if r.Checkouts[i].ID == id {
r.Checkouts = append(r.Checkouts[:i], r.Checkouts[i+1:]...)
return true
}
}
return false
}
func newID(name, root string) string {
h := sha1.Sum([]byte(filepath.Clean(root)))
suffix := hex.EncodeToString(h[:])[:8]
base := slug(name)
if base == "" {
base = "checkout"
}
return base + "-" + suffix
}
func slug(value string) string {
value = strings.ToLower(strings.TrimSpace(value))
value = strings.ReplaceAll(value, " ", "-")
var b strings.Builder
lastDash := false
for _, r := range value {
isAlphaNum := (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9')
if isAlphaNum {
b.WriteRune(r)
lastDash = false
continue
}
if !lastDash {
b.WriteRune('-')
lastDash = true
}
}
return strings.Trim(b.String(), "-")
}
func compare(a, b CheckoutRecord) int {
return strings.Compare(a.Name, b.Name)
}

View File

@@ -0,0 +1,69 @@
package session
import (
"errors"
"os"
"path/filepath"
"time"
"bdev/internal/config"
"gopkg.in/yaml.v3"
)
func Load(checkoutID string) (*Session, error) {
path, err := sessionPath(checkoutID)
if err != nil {
return nil, err
}
data, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
return nil, errors.New("no active session")
}
return nil, err
}
var s Session
if err := yaml.Unmarshal(data, &s); err != nil {
return nil, err
}
return &s, nil
}
func Save(s *Session) error {
if err := config.EnsureLayout(); err != nil {
return err
}
s.UpdatedAt = time.Now()
if s.CreatedAt.IsZero() {
s.CreatedAt = s.UpdatedAt
}
path, err := sessionPath(s.CheckoutID)
if err != nil {
return err
}
data, err := yaml.Marshal(s)
if err != nil {
return err
}
return os.WriteFile(path, data, 0o644)
}
func Delete(checkoutID string) error {
path, err := sessionPath(checkoutID)
if err != nil {
return err
}
if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
return err
}
return nil
}
func sessionPath(checkoutID string) (string, error) {
dir, err := config.SessionsDir()
if err != nil {
return "", err
}
return filepath.Join(dir, checkoutID+".yaml"), nil
}

View File

@@ -0,0 +1,22 @@
package session
import "time"
type ConflictEntry struct {
Path string `yaml:"path"`
Stage string `yaml:"stage"`
RejectPath string `yaml:"reject_path,omitempty"`
PatchContent string `yaml:"patch_content,omitempty"`
Error string `yaml:"error,omitempty"`
}
type Session struct {
CheckoutID string `yaml:"checkout_id"`
Kind string `yaml:"kind"`
FromRepoRev string `yaml:"from_repo_rev,omitempty"`
ToRepoRev string `yaml:"to_repo_rev,omitempty"`
Pending []ConflictEntry `yaml:"pending"`
Resolved []string `yaml:"resolved,omitempty"`
CreatedAt time.Time `yaml:"created_at"`
UpdatedAt time.Time `yaml:"updated_at"`
}

View File

@@ -2,57 +2,95 @@ package ui
import (
"fmt"
"strings"
)
"os"
"sync"
"time"
var (
StepPrefix = TitleStyle.Render(">")
InfoPrefix = SubtitleStyle.Render("i")
OkPrefix = SuccessStyle.Render("✓")
WarnPrefix = WarningStyle.Render("!")
ErrorPrefix = ErrorStyle.Render("x")
"golang.org/x/term"
)
type Activity struct {
Verbose bool
enabled bool
spinner *spinner
}
func NewActivity(verbose bool) *Activity {
return &Activity{Verbose: verbose}
func NewActivity(enabled bool) *Activity {
return &Activity{
enabled: enabled && term.IsTerminal(int(os.Stderr.Fd())),
}
}
func (a *Activity) Header(title string) {
fmt.Println(TitleStyle.Render(title))
fmt.Println()
func (a *Activity) Start(format string, args ...any) func(ok bool, detail string) {
label := fmt.Sprintf(format, args...)
if a.enabled {
a.spinner = newSpinner(label)
a.spinner.start()
} else {
fmt.Fprintf(os.Stderr, " • %s\n", label)
}
start := time.Now()
return func(ok bool, detail string) {
if a.spinner != nil {
a.spinner.stop(ok, detail, time.Since(start))
a.spinner = nil
return
}
status := Success("done")
if !ok {
status = Warn("warn")
}
if detail != "" {
fmt.Fprintf(os.Stderr, " %s %s (%s)\n", status, detail, time.Since(start).Round(time.Millisecond))
return
}
fmt.Fprintf(os.Stderr, " %s (%s)\n", status, time.Since(start).Round(time.Millisecond))
}
}
func (a *Activity) Step(format string, args ...any) {
fmt.Printf(" %s %s\n", StepPrefix, fmt.Sprintf(format, args...))
type spinner struct {
label string
stopCh chan struct{}
wg sync.WaitGroup
}
func (a *Activity) Info(format string, args ...any) {
fmt.Printf(" %s %s\n", InfoPrefix, fmt.Sprintf(format, args...))
func newSpinner(label string) *spinner {
return &spinner{
label: label,
stopCh: make(chan struct{}),
}
}
func (a *Activity) Detail(format string, args ...any) {
if !a.Verbose {
func (s *spinner) start() {
frames := []string{"⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"}
s.wg.Add(1)
go func() {
defer s.wg.Done()
i := 0
ticker := time.NewTicker(90 * time.Millisecond)
defer ticker.Stop()
for {
fmt.Fprintf(os.Stderr, "\r %s %s", frames[i%len(frames)], s.label)
i++
select {
case <-ticker.C:
case <-s.stopCh:
return
}
}
}()
}
func (s *spinner) stop(ok bool, detail string, elapsed time.Duration) {
close(s.stopCh)
s.wg.Wait()
symbol := Success("✓")
if !ok {
symbol = Warn("!")
}
fmt.Fprint(os.Stderr, "\r")
if detail != "" {
fmt.Fprintf(os.Stderr, " %s %s %s (%s)\n", symbol, s.label, Muted(detail), elapsed.Round(time.Millisecond))
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)))
fmt.Fprintf(os.Stderr, " %s %s (%s)\n", symbol, s.label, elapsed.Round(time.Millisecond))
}

View File

@@ -0,0 +1,21 @@
package ui
import "github.com/fatih/color"
var (
titleColor = color.New(color.Bold, color.FgCyan)
labelColor = color.New(color.Bold)
valueColor = color.New(color.FgHiWhite)
successColor = color.New(color.FgGreen, color.Bold)
warnColor = color.New(color.FgYellow, color.Bold)
errorColor = color.New(color.FgRed, color.Bold)
mutedColor = color.New(color.Faint)
)
func Title(value string) string { return titleColor.Sprint(value) }
func Label(value string) string { return labelColor.Sprint(value) }
func Value(value string) string { return valueColor.Sprint(value) }
func Success(value string) string { return successColor.Sprint(value) }
func Warn(value string) string { return warnColor.Sprint(value) }
func Error(value string) string { return errorColor.Sprint(value) }
func Muted(value string) string { return mutedColor.Sprint(value) }

View File

@@ -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()
}

View File

@@ -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)
}

View File

@@ -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)
)

View File

@@ -7,12 +7,9 @@ import (
"bdev/cmd"
)
var version = "dev"
func main() {
cmd.SetVersion(version)
if err := cmd.Execute(); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(2)
os.Exit(1)
}
}