From dc09034d9f68b5890b172607bfce403eb18da351 Mon Sep 17 00:00:00 2001 From: Kylian Bardini Date: Fri, 29 May 2026 11:28:37 +0200 Subject: [PATCH 1/6] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20refactor(worktree):=20?= =?UTF-8?q?expose=20`is=5Fdirty`=20scanner=20for=20reuse?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Hoist the dirty-tree check out of `compute_status` into a public `worktree::is_dirty` so `gwm sync` (#24) and the status column share one definition of "dirty" — staged, unstaged, or untracked, ignored files excluded. `compute_status` now delegates to it, preserving the `unknown` fallback on a libgit2 status error. --- src/worktree.rs | 25 ++++++++++++++++++------- 1 file changed, 18 insertions(+), 7 deletions(-) diff --git a/src/worktree.rs b/src/worktree.rs index c004dbd..c13c640 100644 --- a/src/worktree.rs +++ b/src/worktree.rs @@ -92,18 +92,29 @@ impl BranchStatus { } } -/// Compute the working-tree + upstream status of a single repo / linked worktree. -fn compute_status(repo: &Repository) -> BranchStatus { - let mut out = BranchStatus::default(); - - // Dirty check +/// True when the worktree at `repo` carries staged, unstaged, or +/// untracked changes (ignored files excluded). Shares its +/// `StatusOptions` shape with [`compute_status`] so the status column +/// and `gwm sync`'s dirty-tree refusal (issue #24) agree on what +/// "dirty" means. +pub fn is_dirty(repo: &Repository) -> Result { let mut opts = StatusOptions::new(); opts .include_untracked(true) .include_ignored(false) .recurse_untracked_dirs(true); - match repo.statuses(Some(&mut opts)) { - Ok(s) => out.is_dirty = !s.is_empty(), + let statuses = repo.statuses(Some(&mut opts))?; + Ok(!statuses.is_empty()) +} + +/// Compute the working-tree + upstream status of a single repo / linked worktree. +fn compute_status(repo: &Repository) -> BranchStatus { + let mut out = BranchStatus::default(); + + // Dirty check — reuse the shared `is_dirty` scanner so the column + // and `gwm sync` can never disagree on dirtiness. + match is_dirty(repo) { + Ok(dirty) => out.is_dirty = dirty, Err(_) => out.unknown = true, } From f72d686c9987221320ea9d0a9cfe51d98b1a2043 Mon Sep 17 00:00:00 2001 From: Kylian Bardini Date: Fri, 29 May 2026 11:28:37 +0200 Subject: [PATCH 2/6] =?UTF-8?q?=E2=9C=A8=20feat(sync):=20`gwm=20sync`=20?= =?UTF-8?q?=E2=80=94=20fetch=20+=20rebase/merge=20onto=20upstream=20(#24)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New `gwm sync [] [--merge]` subcommand. Resolves the target worktree (defaults to the CWD worktree), runs `git fetch` for its upstream's remote, then rebases the branch onto the upstream — or merges with `--merge`. Reports the outcome with the ✓ sigil. Read-side inspection (dirty check, upstream resolution, ahead/behind) goes through libgit2; the mutating steps (fetch/rebase/merge) shell out to `git` so the user's configured credentials and helpers are used — same rationale as the existing sidebar `git log`/`git status` shell-outs. Guards, per the issue: - dirty working tree → refuse before touching the remote; - no upstream configured → actionable error naming the fix; - conflicting rebase/merge → abort so the worktree stays usable, then surface a conflict error telling the user to reconcile by hand. Tests (TDD): offline integration suite in tests/sync_tests.rs drives a bare `origin` + tracking `local` fixture through up-to-date / rebase / merge / conflict / dirty / no-upstream paths; pure formatter contracts in tests/cli_format_tests.rs; E2E + help canary in tests/cli_binary.rs. --- src/cli.rs | 76 ++++++++++++ src/lib.rs | 1 + src/sync.rs | 222 +++++++++++++++++++++++++++++++++++ tests/cli_binary.rs | 32 +++++ tests/cli_format_tests.rs | 64 +++++++++- tests/sync_tests.rs | 239 ++++++++++++++++++++++++++++++++++++++ 6 files changed, 633 insertions(+), 1 deletion(-) create mode 100644 src/sync.rs create mode 100644 tests/sync_tests.rs diff --git a/src/cli.rs b/src/cli.rs index ddb9c64..374eae7 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -16,6 +16,7 @@ use crate::multiplexer::{ }; use crate::naming::{parse_branch, BranchSpec}; use crate::pr_templates::{self, PrTemplateContext}; +use crate::sync::{self, SyncAction, SyncReport, SyncStrategy}; use crate::trust::{self, TrustLedger, TrustMode, TrustOutcome}; use crate::worktree; use clap::{CommandFactory, Parser, Subcommand, ValueEnum}; @@ -187,6 +188,25 @@ pub enum Command { #[arg(long, value_name = "PHASES")] skip_hooks: Option, }, + /// Fetch + rebase (or merge) a worktree's branch onto its upstream. + /// + /// Resolves the target worktree (defaults to the CWD worktree when + /// no pattern is given), runs `git fetch` for its upstream's remote, + /// then rebases the branch onto the upstream — or merges with + /// `--merge`. Reports the outcome with the same ✓ / ! / ✗ sigils as + /// the rest of gwm. + /// + /// Refuses up front on a dirty working tree (commit or stash first) + /// and on a branch with no upstream configured. A conflicting + /// rebase/merge is aborted so the worktree stays usable, and the + /// user is told to reconcile by hand. Issue #24. + Sync { + /// Worktree name/pattern; defaults to the worktree containing the CWD. + pattern: Option, + /// Merge the upstream instead of rebasing onto it. + #[arg(long)] + merge: bool, + }, /// Prune stale worktree references (admin files without a working dir). Prune { /// List the prunable worktrees (name + path + reason) without @@ -738,6 +758,7 @@ pub fn run(cli: Cli) -> Result<()> { } => cmd_remove(pattern, delete_branch, dry_run, force, skip_hooks, mode), Command::Path { pattern } => cmd_path(pattern), Command::Bootstrap { target, skip_hooks } => cmd_bootstrap(target, skip_hooks, mode), + Command::Sync { pattern, merge } => cmd_sync(pattern, merge), Command::Prune { dry_run } => cmd_prune(dry_run), Command::Doctor => cmd_doctor(), Command::Types { gitmoji } => cmd_types(gitmoji), @@ -1503,6 +1524,61 @@ fn cmd_bootstrap(target: Option, skip_hooks: Option, trust_mode: Ok(()) } +fn cmd_sync(pattern: Option, merge: bool) -> Result<()> { + let repo = worktree::discover_repo(None)?; + + // Resolve the target worktree. With a pattern, fuzzy-match like the + // rest of gwm; without one, default to the worktree containing the + // CWD — which, unlike `find_fuzzy`, may legitimately be the main + // worktree (syncing trunk is a valid use). + let (target_path, name) = match pattern { + Some(p) => { + let found = worktree::find_fuzzy(&repo, &p)?; + (found.path, found.name) + } + None => { + let cwd = std::env::current_dir()?; + let name = cwd + .file_name() + .map(|n| n.to_string_lossy().into_owned()) + .unwrap_or_else(|| "worktree".into()); + (cwd, name) + } + }; + + let strategy = if merge { + SyncStrategy::Merge + } else { + SyncStrategy::Rebase + }; + let report = sync::sync(&target_path, strategy)?; + print!("{}", format_sync_report(&name, &report)); + Ok(()) +} + +/// Render a successful [`SyncReport`] as a single ✓ status line. The +/// error paths (dirty tree, missing upstream, conflicts) surface as +/// `GwmError` and are printed by `main`'s top-level handler, so this +/// only ever formats the success cases. +pub fn format_sync_report(name: &str, report: &SyncReport) -> String { + match report.action { + SyncAction::UpToDate => { + format!("✓ {} already up to date with {}\n", name, report.upstream) + } + SyncAction::Integrated => { + let verb = match report.strategy { + SyncStrategy::Rebase => "rebased", + SyncStrategy::Merge => "merged", + }; + let plural = if report.behind_before == 1 { "" } else { "s" }; + format!( + "✓ {} {} {} commit{} from {}\n", + name, verb, report.behind_before, plural, report.upstream + ) + } + } +} + fn cmd_prune(dry_run: bool) -> Result<()> { let repo = worktree::discover_repo(None)?; if dry_run { diff --git a/src/lib.rs b/src/lib.rs index 854e84c..6f4cb21 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -22,6 +22,7 @@ pub mod milestones; pub mod multiplexer; pub mod naming; pub mod pr_templates; +pub mod sync; pub mod templating; pub mod trust; pub mod tui; diff --git a/src/sync.rs b/src/sync.rs new file mode 100644 index 0000000..c0c110a --- /dev/null +++ b/src/sync.rs @@ -0,0 +1,222 @@ +//! `gwm sync` (issue #24) — fetch + rebase / merge a worktree's branch +//! onto its configured upstream. +//! +//! The read-side inspection (dirty check, upstream resolution, +//! ahead/behind) goes through libgit2; the mutating steps (`fetch`, +//! `rebase`, `merge`) shell out to the `git` binary. That split is +//! deliberate: libgit2's fetch needs the caller to wire credential +//! callbacks (SSH agents, tokens, helpers) to talk to a real remote, +//! whereas the user's `git` already has all of that configured. The +//! existing sidebar previews (`git_log_oneline`, `git_status_short`) +//! shell out for the same reason, so this stays consistent. + +use crate::error::{GwmError, Result}; +use crate::worktree; +use git2::{BranchType, Repository}; +use std::path::Path; +use std::process::Command; + +/// How `gwm sync` reconciles the local branch when it is behind its +/// upstream. Defaults to rebase (linear history, the repo convention); +/// `--merge` opts into a merge commit instead. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SyncStrategy { + Rebase, + Merge, +} + +impl SyncStrategy { + /// The `git` subcommand verb (`rebase` / `merge`). + fn verb(self) -> &'static str { + match self { + SyncStrategy::Rebase => "rebase", + SyncStrategy::Merge => "merge", + } + } +} + +/// What `sync` actually did once preconditions passed. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SyncAction { + /// The branch was already level with (or ahead of) upstream — no + /// integration was needed. + UpToDate, + /// `behind_before` upstream commits were integrated via the chosen + /// strategy. + Integrated, +} + +/// Outcome of a successful `sync` run. Conflicts, dirty trees, and +/// missing upstreams surface as `GwmError` instead — only the +/// non-error paths produce a report. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SyncReport { + /// Local branch shorthand that was synced (e.g. `feat/#24-sync`). + pub branch: String, + /// Upstream tracking ref shorthand (e.g. `origin/main`). + pub upstream: String, + /// Strategy used (rebase / merge). + pub strategy: SyncStrategy, + /// Commits the local branch had that upstream did not, measured + /// before integration. + pub ahead_before: usize, + /// Commits upstream had that the local branch did not, measured + /// after the fetch but before integration. + pub behind_before: usize, + /// What happened. + pub action: SyncAction, +} + +/// Fetch `start`'s upstream, then rebase (or merge) the local branch +/// onto it. `start` may be any path inside the target worktree — the +/// repository is discovered upwards from it. +/// +/// Refuses up front when: +/// - the working tree is dirty (uncommitted changes), +/// - HEAD is detached / unborn (no branch shorthand), +/// - the branch has no upstream configured. +/// +/// On a conflicting rebase/merge the operation is aborted so the +/// worktree is left usable, and a conflict error is returned telling +/// the user to reconcile by hand. +pub fn sync(start: &Path, strategy: SyncStrategy) -> Result { + let repo = Repository::discover(start).map_err(|_| GwmError::NotInGitRepo)?; + let workdir = repo.workdir().ok_or(GwmError::NotInGitRepo)?.to_path_buf(); + + // 1. Refuse to touch a dirty tree — a rebase/merge on top of + // uncommitted work is how people lose changes. + if worktree::is_dirty(&repo)? { + return Err(GwmError::Other( + "worktree has uncommitted changes; commit or stash before syncing".into(), + )); + } + + // 2. Resolve the current branch and its upstream. + let head = repo.head().map_err(|_| GwmError::UnbornHead { + reason: "sync: cannot read HEAD (unborn or unreadable)".into(), + })?; + if !head.is_branch() { + return Err(GwmError::UnbornHead { + reason: "sync: HEAD is detached — check out a branch first".into(), + }); + } + let branch_short = head + .shorthand() + .ok_or_else(|| GwmError::UnbornHead { + reason: "sync: HEAD has no branch name".into(), + })? + .to_string(); + let head_refname = head.name().map(|s| s.to_string()); + + let local = repo + .find_branch(&branch_short, BranchType::Local) + .map_err(|_| GwmError::Other(format!("sync: local branch '{branch_short}' not found")))?; + let upstream = local.upstream().map_err(|_| { + GwmError::Other(format!( + "branch '{branch_short}' has no upstream configured; set one with `git branch --set-upstream-to=/{branch_short}`" + )) + })?; + let upstream_short = upstream + .name() + .ok() + .flatten() + .ok_or_else(|| GwmError::Other("sync: upstream tracking ref has no name".into()))? + .to_string(); + + // The remote to fetch. `branch_upstream_remote` wants the full + // refname (`refs/heads/`). If the upstream is a local + // branch (no remote), fall back to a bare `git fetch`. + let remote = head_refname + .as_deref() + .and_then(|rn| repo.branch_upstream_remote(rn).ok()) + .and_then(|buf| buf.as_str().map(|s| s.to_string())); + + // 3. Fetch. After this the in-memory `repo` ref cache is stale, so + // everything past here re-resolves against a freshly opened repo. + match &remote { + Some(r) => run_git(&workdir, &["fetch", r])?, + None => run_git(&workdir, &["fetch"])?, + }; + + // 4. Recompute ahead/behind against the now-updated upstream. + let repo = Repository::discover(start).map_err(|_| GwmError::NotInGitRepo)?; + let (ahead_before, behind_before) = ahead_behind(&repo, &branch_short)?; + + if behind_before == 0 { + return Ok(SyncReport { + branch: branch_short, + upstream: upstream_short, + strategy, + ahead_before, + behind_before, + action: SyncAction::UpToDate, + }); + } + + // 5. Integrate. On failure (conflicts), abort so the worktree is + // not left mid-rebase/merge, then surface a conflict error. + let integrate = match strategy { + SyncStrategy::Rebase => run_git(&workdir, &["rebase", &upstream_short]), + SyncStrategy::Merge => run_git(&workdir, &["merge", "--no-edit", &upstream_short]), + }; + if integrate.is_err() { + let _ = run_git(&workdir, &[strategy.verb(), "--abort"]); + return Err(GwmError::Other(format!( + "{} onto {} hit conflicts and was aborted; reconcile manually with `git {} {}`", + strategy.verb(), + upstream_short, + strategy.verb(), + upstream_short + ))); + } + + Ok(SyncReport { + branch: branch_short, + upstream: upstream_short, + strategy, + ahead_before, + behind_before, + action: SyncAction::Integrated, + }) +} + +/// Ahead / behind counts of `branch` versus its upstream, resolved +/// fresh from disk. Returns `(ahead, behind)`. +fn ahead_behind(repo: &Repository, branch: &str) -> Result<(usize, usize)> { + let local = repo + .find_branch(branch, BranchType::Local) + .map_err(|_| GwmError::Other(format!("sync: local branch '{branch}' not found")))?; + let upstream = local + .upstream() + .map_err(|_| GwmError::Other(format!("branch '{branch}' has no upstream configured")))?; + let local_oid = local + .get() + .target() + .ok_or_else(|| GwmError::Other(format!("sync: branch '{branch}' has no commit")))?; + let up_oid = upstream + .get() + .target() + .ok_or_else(|| GwmError::Other("sync: upstream has no commit".into()))?; + let (ahead, behind) = repo.graph_ahead_behind(local_oid, up_oid)?; + Ok((ahead, behind)) +} + +/// Run `git -C `, returning stdout on success or a +/// `CommandFailed` error carrying the verb and stderr on failure. +fn run_git(dir: &Path, args: &[&str]) -> Result { + let out = Command::new("git") + .arg("-C") + .arg(dir) + .args(args) + .output() + .map_err(|e| GwmError::CommandFailed(format!("git {} failed to spawn: {}", args.join(" "), e)))?; + if !out.status.success() { + return Err(GwmError::CommandFailed(format!( + "git {} exited {}: {}", + args.join(" "), + out.status, + String::from_utf8_lossy(&out.stderr).trim() + ))); + } + Ok(String::from_utf8_lossy(&out.stdout).into_owned()) +} diff --git a/tests/cli_binary.rs b/tests/cli_binary.rs index 1076a2f..74bf10f 100644 --- a/tests/cli_binary.rs +++ b/tests/cli_binary.rs @@ -33,6 +33,8 @@ fn help_prints_subcommands() { .stdout(predicate::str::contains(" path ")) .stdout(predicate::str::contains("[aliases: cd]")) .stdout(predicate::str::contains(" bootstrap ")) + // Issue #24: fetch + rebase/merge a worktree onto its upstream. + .stdout(predicate::str::contains(" sync ")) .stdout(predicate::str::contains(" prune ")) .stdout(predicate::str::contains(" completions ")) .stdout(predicate::str::contains(" shell-init ")) @@ -344,6 +346,36 @@ fn labels_list_with_no_declared_labels_is_a_no_op() { .stdout(predicate::str::contains("0 labels declared")); } +// --- sync (issue #24) ------------------------------------------------------- + +#[test] +fn sync_unknown_pattern_errors() { + // A pattern that resolves to no worktree must fail loudly with the + // worktree name, not silently no-op. + let (dir, _repo) = init_repo(); + let mut cmd = Command::cargo_bin("gwm").unwrap(); + cmd + .current_dir(dir.path()) + .args(["sync", "does-not-exist"]) + .assert() + .failure() + .stderr(predicate::str::contains("does-not-exist")); +} + +#[test] +fn sync_in_repo_without_upstream_reports_missing_upstream() { + // `gwm sync` with no pattern targets the CWD worktree. A fresh repo + // on `main` with no remote has no upstream → clear, actionable error. + let (dir, _repo) = init_repo(); + let mut cmd = Command::cargo_bin("gwm").unwrap(); + cmd + .current_dir(dir.path()) + .arg("sync") + .assert() + .failure() + .stderr(predicate::str::contains("upstream")); +} + #[test] fn labels_push_with_no_declared_labels_is_a_no_op() { // Same fast path as `list`. Push must not call `gh` when there's diff --git a/tests/cli_format_tests.rs b/tests/cli_format_tests.rs index db65de0..4b29214 100644 --- a/tests/cli_format_tests.rs +++ b/tests/cli_format_tests.rs @@ -12,7 +12,8 @@ //! not bytes, so worktree names or paths containing non-ASCII code //! points still align in a fixed-width terminal. -use gwm::cli::{format_prune_plan, format_remove_plan}; +use gwm::cli::{format_prune_plan, format_remove_plan, format_sync_report}; +use gwm::sync::{SyncAction, SyncReport, SyncStrategy}; use gwm::worktree::PrunableEntry; use std::path::{Path, PathBuf}; @@ -175,3 +176,64 @@ fn prune_plan_aligns_columns_in_unicode_chars_not_bytes() { "reason column drifted across rows ({reason_offsets:?}) — width must be in chars, not bytes:\n{out}" ); } + +// --- format_sync_report (issue #24) ----------------------------------------- + +#[test] +fn sync_report_up_to_date_renders_ok_sigil_and_upstream() { + let report = SyncReport { + branch: "main".into(), + upstream: "origin/main".into(), + strategy: SyncStrategy::Rebase, + ahead_before: 0, + behind_before: 0, + action: SyncAction::UpToDate, + }; + let out = format_sync_report("feat-24-sync", &report); + + assert!(out.starts_with('✓'), "up-to-date should use the success sigil: {out}"); + assert!(out.contains("feat-24-sync"), "worktree name missing: {out}"); + assert!(out.contains("origin/main"), "upstream label missing: {out}"); + assert!( + out.contains("up to date") || out.contains("up-to-date"), + "should state the branch is up to date: {out}" + ); +} + +#[test] +fn sync_report_rebased_states_commit_count_and_strategy() { + let report = SyncReport { + branch: "feat/#24-sync".into(), + upstream: "origin/main".into(), + strategy: SyncStrategy::Rebase, + ahead_before: 1, + behind_before: 3, + action: SyncAction::Integrated, + }; + let out = format_sync_report("feat-24-sync", &report); + + assert!( + out.starts_with('✓'), + "successful integration uses the success sigil: {out}" + ); + assert!(out.contains("rebased"), "rebase strategy should be named: {out}"); + assert!(out.contains('3'), "the integrated commit count should appear: {out}"); + assert!(out.contains("origin/main"), "upstream label missing: {out}"); +} + +#[test] +fn sync_report_merged_names_the_merge_strategy() { + let report = SyncReport { + branch: "feat/#24-sync".into(), + upstream: "origin/main".into(), + strategy: SyncStrategy::Merge, + ahead_before: 0, + behind_before: 2, + action: SyncAction::Integrated, + }; + let out = format_sync_report("feat-24-sync", &report); + + assert!(out.contains("merged"), "merge strategy should be named: {out}"); + assert!(!out.contains("rebased"), "must not claim a rebase when merging: {out}"); + assert!(out.contains('2'), "the integrated commit count should appear: {out}"); +} diff --git a/tests/sync_tests.rs b/tests/sync_tests.rs new file mode 100644 index 0000000..ddc6eb2 --- /dev/null +++ b/tests/sync_tests.rs @@ -0,0 +1,239 @@ +//! Integration tests for `gwm sync` (issue #24) — fetch + rebase / merge +//! a worktree's branch onto its upstream. +//! +//! These exercise the public `gwm::sync::sync` entry point against +//! fully offline fixtures: a bare `origin` repo on the local +//! filesystem plays the remote, a `local` clone tracks `origin/main`, +//! and a throwaway `seed` clone is used to push "upstream" commits. +//! No network, deterministic — `git fetch ` against a `file://` +//! style path needs only the `git` binary, which CI always has. + +use gwm::sync::{self, SyncAction, SyncStrategy}; +use std::path::{Path, PathBuf}; +use std::process::Command; +use tempfile::TempDir; + +/// Run `git -C ` with a deterministic identity and no GPG +/// signing, asserting success. Test-only helper — panics are the right +/// failure mode here. +fn git(dir: &Path, args: &[&str]) { + let out = Command::new("git") + .arg("-C") + .arg(dir) + // Global `-c` options must precede the subcommand. + .args(["-c", "commit.gpgsign=false"]) + .args(args) + .env("GIT_AUTHOR_NAME", "gwm-test") + .env("GIT_AUTHOR_EMAIL", "gwm@test") + .env("GIT_COMMITTER_NAME", "gwm-test") + .env("GIT_COMMITTER_EMAIL", "gwm@test") + .env("GIT_CONFIG_GLOBAL", "/dev/null") + .env("GIT_CONFIG_SYSTEM", "/dev/null") + .output() + .unwrap_or_else(|e| panic!("git {:?} failed to spawn: {e}", args)); + assert!( + out.status.success(), + "git {:?} exited {}: {}", + args, + out.status, + String::from_utf8_lossy(&out.stderr) + ); +} + +/// Capture stdout of `git -C `, trimmed. +fn git_out(dir: &Path, args: &[&str]) -> String { + let out = Command::new("git") + .arg("-C") + .arg(dir) + .args(args) + .env("GIT_CONFIG_GLOBAL", "/dev/null") + .env("GIT_CONFIG_SYSTEM", "/dev/null") + .output() + .unwrap(); + assert!( + out.status.success(), + "git {:?} failed: {}", + args, + String::from_utf8_lossy(&out.stderr) + ); + String::from_utf8_lossy(&out.stdout).trim().to_string() +} + +fn write(path: &Path, contents: &str) { + std::fs::write(path, contents).unwrap(); +} + +/// Build `origin` (bare) + `local` (clone tracking `origin/main`, one +/// commit). Returns the tempdir guard and the `local` working dir. +fn local_tracking_origin() -> (TempDir, PathBuf, PathBuf) { + let td = TempDir::new().unwrap(); + let root = td.path(); + let origin = root.join("origin"); + let local = root.join("local"); + std::fs::create_dir_all(&origin).unwrap(); + std::fs::create_dir_all(&local).unwrap(); + + // Bare origin on `main`. + let out = Command::new("git") + .args(["init", "--bare", "-b", "main"]) + .arg(&origin) + .output() + .unwrap(); + assert!( + out.status.success(), + "init bare: {}", + String::from_utf8_lossy(&out.stderr) + ); + + // Local repo with one commit, pushed with upstream tracking. + let out = Command::new("git") + .args(["init", "-b", "main"]) + .arg(&local) + .output() + .unwrap(); + assert!( + out.status.success(), + "init local: {}", + String::from_utf8_lossy(&out.stderr) + ); + write(&local.join("file.txt"), "base\n"); + git(&local, &["add", "-A"]); + git(&local, &["commit", "-m", "init"]); + git(&local, &["remote", "add", "origin", origin.to_str().unwrap()]); + git(&local, &["push", "-u", "origin", "main"]); + + (td, origin, local) +} + +/// Push one extra commit to `origin/main` from a throwaway clone so the +/// `local` repo becomes one commit behind upstream after a fetch. +fn push_upstream_commit(root: &Path, origin: &Path, file: &str, contents: &str, msg: &str) { + let seed = root.join(format!("seed-{msg}")); + let out = Command::new("git") + .arg("clone") + .arg(origin) + .arg(&seed) + .output() + .unwrap(); + assert!( + out.status.success(), + "clone seed: {}", + String::from_utf8_lossy(&out.stderr) + ); + write(&seed.join(file), contents); + git(&seed, &["add", "-A"]); + git(&seed, &["commit", "-m", msg]); + git(&seed, &["push", "origin", "main"]); +} + +#[test] +fn sync_refuses_dirty_worktree() { + let (_td, _origin, local) = local_tracking_origin(); + // Uncommitted change → sync must refuse before touching the remote. + write(&local.join("file.txt"), "dirty edit\n"); + + let err = sync::sync(&local, SyncStrategy::Rebase).unwrap_err(); + let msg = err.to_string().to_lowercase(); + assert!( + msg.contains("uncommitted") || msg.contains("stash"), + "dirty refusal should mention uncommitted/stash, got: {msg}" + ); +} + +#[test] +fn sync_errors_without_upstream() { + // A repo with a committed branch but no upstream configured. + let td = TempDir::new().unwrap(); + let local = td.path().join("solo"); + let out = Command::new("git") + .args(["init", "-b", "main"]) + .arg(&local) + .output() + .unwrap(); + assert!(out.status.success()); + write(&local.join("file.txt"), "base\n"); + git(&local, &["add", "-A"]); + git(&local, &["commit", "-m", "init"]); + + let err = sync::sync(&local, SyncStrategy::Rebase).unwrap_err(); + let msg = err.to_string().to_lowercase(); + assert!( + msg.contains("upstream"), + "missing-upstream error should mention upstream, got: {msg}" + ); +} + +#[test] +fn sync_reports_up_to_date_when_level_with_upstream() { + let (_td, _origin, local) = local_tracking_origin(); + + let report = sync::sync(&local, SyncStrategy::Rebase).unwrap(); + assert_eq!(report.action, SyncAction::UpToDate, "no upstream commits ⇒ up to date"); + assert_eq!(report.behind_before, 0); + assert_eq!(report.branch, "main"); + assert!( + report.upstream.contains("main"), + "upstream label should name the tracked ref" + ); +} + +#[test] +fn sync_rebases_branch_that_is_behind_upstream() { + let (td, origin, local) = local_tracking_origin(); + push_upstream_commit(td.path(), &origin, "file.txt", "base\nupstream\n", "upstream-change"); + + let report = sync::sync(&local, SyncStrategy::Rebase).unwrap(); + assert_eq!( + report.action, + SyncAction::Integrated, + "behind branch should integrate upstream" + ); + assert_eq!(report.behind_before, 1, "exactly one upstream commit was pending"); + assert_eq!(report.strategy, SyncStrategy::Rebase); + + // After rebase the local tip must equal origin/main's tip. + let local_head = git_out(&local, &["rev-parse", "HEAD"]); + let origin_head = git_out(&origin, &["rev-parse", "main"]); + assert_eq!( + local_head, origin_head, + "local HEAD should match upstream after a clean rebase" + ); +} + +#[test] +fn sync_merge_strategy_integrates_upstream() { + let (td, origin, local) = local_tracking_origin(); + push_upstream_commit(td.path(), &origin, "other.txt", "added upstream\n", "upstream-feature"); + + let report = sync::sync(&local, SyncStrategy::Merge).unwrap(); + assert_eq!(report.action, SyncAction::Integrated); + assert_eq!(report.strategy, SyncStrategy::Merge); + assert_eq!(report.behind_before, 1); + + // The upstream file must now be present in the local worktree. + assert!( + local.join("other.txt").exists(), + "merge should bring the upstream-added file into the worktree" + ); +} + +#[test] +fn sync_aborts_and_errors_on_conflict() { + let (td, origin, local) = local_tracking_origin(); + // Upstream edits file.txt one way… + push_upstream_commit(td.path(), &origin, "file.txt", "base\nUPSTREAM\n", "upstream-edit"); + // …local edits the same line differently and commits, so a rebase conflicts. + write(&local.join("file.txt"), "base\nLOCAL\n"); + git(&local, &["add", "-A"]); + git(&local, &["commit", "-m", "local-edit"]); + + let err = sync::sync(&local, SyncStrategy::Rebase).unwrap_err(); + let msg = err.to_string().to_lowercase(); + assert!(msg.contains("conflict"), "conflict error should say so, got: {msg}"); + + // The failed rebase must be aborted: no rebase-in-progress state left behind. + assert!( + !local.join(".git/rebase-merge").exists() && !local.join(".git/rebase-apply").exists(), + "sync must abort the rebase so the worktree is left usable" + ); +} From 1e0016aa6d57230f407c4832f8d8da7208a6d4f6 Mon Sep 17 00:00:00 2001 From: Kylian Bardini Date: Fri, 29 May 2026 11:28:52 +0200 Subject: [PATCH 3/6] =?UTF-8?q?=F0=9F=93=9D=20docs(changelog):=20note=20`g?= =?UTF-8?q?wm=20sync`=20under=20[Unreleased]=20(#24)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cd08ca3..fe2b0ea 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,7 +10,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] -_No changes yet — entries land here as PRs merge into `dev`, then move to a per-RC file under `changelogs/pre-releases/` when the next RC is cut._ +### Added + +- **`gwm sync [] [--merge]`** ([#24](https://github.com/kbrdn1/gwm-cli/issues/24)). Fetch a worktree's upstream and rebase its branch onto it — or merge with `--merge`. Resolves the target worktree by fuzzy pattern (defaults to the CWD worktree). Refuses a dirty working tree and a branch with no upstream; a conflicting rebase/merge is aborted so the worktree stays usable, with an actionable error. Read-side inspection uses libgit2; the fetch/rebase/merge steps shell out to `git` so the user's configured credentials are honoured. ## Past releases From 4f3e3e4cd958b06ab57a76000f06109406b603ea Mon Sep 17 00:00:00 2001 From: Kylian Bardini Date: Fri, 29 May 2026 11:37:11 +0200 Subject: [PATCH 4/6] =?UTF-8?q?=F0=9F=93=9D=20docs(sync):=20document=20`gw?= =?UTF-8?q?m=20sync`=20in=20CLI=20reference=20+=20skill=20(#24)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a `gwm sync` section to the CLI reference (usage, target resolution, the three guard rails, and the libgit2-vs-git-shell-out split), list it in the CLI index, and surface it in the bundled SKILL.md cheat-sheets (full command list, when-to-use line, quick reference). --- docs/3.cli/1.reference.md | 20 ++++++++++++++++++++ docs/3.cli/index.md | 2 +- skills/SKILL.md | 6 +++++- 3 files changed, 26 insertions(+), 2 deletions(-) diff --git a/docs/3.cli/1.reference.md b/docs/3.cli/1.reference.md index 3a4af37..1dc30a6 100644 --- a/docs/3.cli/1.reference.md +++ b/docs/3.cli/1.reference.md @@ -154,6 +154,26 @@ gwm bootstrap auth # on a fuzzy-matched name Useful after editing `.gwm.toml` or adding new `[[bootstrap.copy]]` rules. Same `✓ / ! / ✗` report as `gwm create`. +## `gwm sync [] [--merge]` + +Fetch a worktree's upstream and bring its branch up to date — rebase by default, or merge with `--merge`. + +```bash +gwm sync # the CWD worktree, rebase onto upstream +gwm sync auth # a fuzzy-matched worktree +gwm sync auth --merge # merge the upstream instead of rebasing +``` + +Resolves the target like `gwm bootstrap` (fuzzy pattern, defaults to the worktree containing the CWD — which may be the main worktree, so you can sync trunk too). It runs `git fetch` for the upstream's remote, recomputes how far behind the branch is, then integrates only when there's something to integrate. Reports a single `✓` line (`already up to date` / `rebased N commit(s)` / `merged N commit(s)`). + +Guard rails: + +- **Dirty working tree** → refuses before touching the remote (`commit or stash`). A rebase/merge on top of uncommitted work is how changes get lost. +- **No upstream configured** → errors with the `git branch --set-upstream-to=/` fix. +- **Conflict** → the rebase/merge is **aborted** so the worktree is left usable, and you're told to reconcile by hand. + +The fetch / rebase / merge steps shell out to your `git` (so SSH keys, credential helpers, and `insteadOf` rules all apply); the dirty / upstream / ahead-behind inspection uses libgit2. + ## `gwm remove [--delete-branch]` Remove a worktree by fuzzy match. The branch survives by default. diff --git a/docs/3.cli/index.md b/docs/3.cli/index.md index 54aab6b..d22396d 100644 --- a/docs/3.cli/index.md +++ b/docs/3.cli/index.md @@ -9,7 +9,7 @@ navigation: `gwm ` is the scriptable side of gwm — designed to be safe in pipelines, shell completions, and pre-commit hooks. Every subcommand exits with a meaningful code (`0` ok, `1` warning, `2` failure) so you can wire `gwm doctor` into CI without parsing stdout. -- **[Reference](/cli/reference)** — every subcommand, exhaustive (`init`, `create`, `list`, `path`, `cd`, `switch`, `bootstrap`, `remove`, `prune`, `link`, `unlink`, `open`, `status`, `doctor`, `tmux`, `zellij`, `completions`, `shell-init`). +- **[Reference](/cli/reference)** — every subcommand, exhaustive (`init`, `create`, `list`, `path`, `cd`, `switch`, `bootstrap`, `sync`, `remove`, `prune`, `link`, `unlink`, `open`, `status`, `doctor`, `tmux`, `zellij`, `completions`, `shell-init`). - **[Shell completions](/cli/completions)** — generate completion scripts for zsh / bash / fish / PowerShell / elvish, plus dynamic worktree-name completion via `gwm list --format=names`. - **[Multiplexer integration](/cli/multiplexer)** — `gwm tmux` / `gwm zellij` to open a worktree in a new tab or pane of the current session. diff --git a/skills/SKILL.md b/skills/SKILL.md index 4fd326e..ca13372 100644 --- a/skills/SKILL.md +++ b/skills/SKILL.md @@ -12,7 +12,7 @@ Source: https://github.com/kbrdn1/gwm-cli — current version: `0.6.0`. ## When to use this skill -- User runs or asks about any `gwm `: `init`, `list`, `create`, `remove`, `path` / `cd`, `bootstrap`, `prune`, `doctor`, `types`, `completions`, `shell-init`, `switch` (alias `s`), `tmux`, `zellij`, `link`, `unlink`, `open`, `status`. +- User runs or asks about any `gwm `: `init`, `list`, `create`, `remove`, `path` / `cd`, `bootstrap`, `sync`, `prune`, `doctor`, `types`, `completions`, `shell-init`, `switch` (alias `s`), `tmux`, `zellij`, `link`, `unlink`, `open`, `status`. - User opens the TUI by running `gwm` alone in a repo, or the picker via `gwm switch` / `gwm s`. - User mentions `.gwm.toml` (per-repo config) or any of its sections: `[worktree]`, `[doctor]`, `[tui]`, `[tui.open]`, `[git_tui]`, `[review]`, `[[bootstrap.copy]]`, `[[bootstrap.guard]]`, `[[bootstrap.no_symlink]]`, `[[bootstrap.command]]`, `[bootstrap.fallback.*]`. - User asks about composable `when` predicates (`file_exists:`, `cmd_exists:`, `env_set:`, `env_eq:`, `glob_exists:`) and the `!` / `&&` / `||` operators. @@ -86,6 +86,9 @@ gwm path # print path (fuzzy match) → use $(g gwm cd # alias of `gwm path` gwm bootstrap # re-run bootstrap on cwd worktree gwm bootstrap # ...or on a named worktree +gwm sync # fetch + rebase the cwd worktree onto its upstream +gwm sync # ...or a fuzzy-matched worktree +gwm sync --merge # merge the upstream instead of rebasing gwm remove # remove (fuzzy). Keeps the branch. gwm remove --delete-branch # also drop the local branch gwm prune # clean stale .git/worktrees entries @@ -698,6 +701,7 @@ gwm list # list worktrees gwm path|cd # print path gwm switch | gwm s | gcd # interactive picker (cd via shell wrapper) gwm bootstrap [pat] # re-run bootstrap +gwm sync [pat] [--merge] # fetch + rebase (or merge) onto upstream gwm remove [-b] # remove (-b drops branch) gwm prune # clean stale refs gwm types # show branch types From 50465168336a057296fccd0ec8e71f6951e72012 Mon Sep 17 00:00:00 2001 From: Kylian Bardini Date: Fri, 29 May 2026 11:44:40 +0200 Subject: [PATCH 5/6] =?UTF-8?q?=E2=9C=85=20test(sync):=20make=20fixtures?= =?UTF-8?q?=20robust=20on=20CI=20runners=20without=20global=20git=20config?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The rebase/merge that `sync()` runs shells out to `git` with no identity env, so on a GitHub runner (no global user.name/user.email) those commits would fail with "committer identity unknown". Pin identity + commit.gpgsign=false as LOCAL config on the fixture repo so sync's git invocations resolve them. Also drop the `/dev/null` GIT_CONFIG_GLOBAL/SYSTEM envs — not portable on Windows. Validated with HOME + GIT_CONFIG_GLOBAL/SYSTEM pointed at an empty dir. --- tests/sync_tests.rs | 17 +++++++---------- 1 file changed, 7 insertions(+), 10 deletions(-) diff --git a/tests/sync_tests.rs b/tests/sync_tests.rs index ddc6eb2..441f2a2 100644 --- a/tests/sync_tests.rs +++ b/tests/sync_tests.rs @@ -27,8 +27,6 @@ fn git(dir: &Path, args: &[&str]) { .env("GIT_AUTHOR_EMAIL", "gwm@test") .env("GIT_COMMITTER_NAME", "gwm-test") .env("GIT_COMMITTER_EMAIL", "gwm@test") - .env("GIT_CONFIG_GLOBAL", "/dev/null") - .env("GIT_CONFIG_SYSTEM", "/dev/null") .output() .unwrap_or_else(|e| panic!("git {:?} failed to spawn: {e}", args)); assert!( @@ -42,14 +40,7 @@ fn git(dir: &Path, args: &[&str]) { /// Capture stdout of `git -C `, trimmed. fn git_out(dir: &Path, args: &[&str]) -> String { - let out = Command::new("git") - .arg("-C") - .arg(dir) - .args(args) - .env("GIT_CONFIG_GLOBAL", "/dev/null") - .env("GIT_CONFIG_SYSTEM", "/dev/null") - .output() - .unwrap(); + let out = Command::new("git").arg("-C").arg(dir).args(args).output().unwrap(); assert!( out.status.success(), "git {:?} failed: {}", @@ -96,6 +87,12 @@ fn local_tracking_origin() -> (TempDir, PathBuf, PathBuf) { "init local: {}", String::from_utf8_lossy(&out.stderr) ); + // Pin identity + no-signing as LOCAL repo config so the rebase/merge + // that `sync()` runs (it shells out to `git` with no identity env) + // succeeds on a CI runner with no global git config. + git(&local, &["config", "user.email", "gwm@test"]); + git(&local, &["config", "user.name", "gwm-test"]); + git(&local, &["config", "commit.gpgsign", "false"]); write(&local.join("file.txt"), "base\n"); git(&local, &["add", "-A"]); git(&local, &["commit", "-m", "init"]); From 319c73cc2c6abb3087cffcba8a89f6af63271de8 Mon Sep 17 00:00:00 2001 From: Kylian Bardini Date: Fri, 29 May 2026 11:55:24 +0200 Subject: [PATCH 6/6] =?UTF-8?q?=F0=9F=90=9B=20fix(sync):=20address=20Copil?= =?UTF-8?q?ot=20review=20on=20#172?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. cmd_sync: in the no-pattern case, discover the worktree *containing* the CWD and report its workdir basename, not the CWD basename — so `gwm sync` from a subdirectory names/targets the worktree root instead of e.g. `src`. 2. sync: don't treat every non-zero rebase/merge as a conflict. Inspect the index for conflict stages (libgit2, language-independent) before aborting; only the genuine-conflict path emits the conflict message, other failures surface the underlying git error verbatim so the user isn't sent down the wrong recovery path. Tests: new E2E pins the subdir naming (bare origin + tracking clone, run from src/deep, asserts the ✓ line names the worktree root). The existing conflict test still exercises the index-based conflict path. --- src/cli.rs | 20 +++++++++------ src/sync.rs | 28 ++++++++++++++++++--- tests/cli_binary.rs | 60 +++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 96 insertions(+), 12 deletions(-) diff --git a/src/cli.rs b/src/cli.rs index 374eae7..b46427a 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -1525,24 +1525,28 @@ fn cmd_bootstrap(target: Option, skip_hooks: Option, trust_mode: } fn cmd_sync(pattern: Option, merge: bool) -> Result<()> { - let repo = worktree::discover_repo(None)?; - - // Resolve the target worktree. With a pattern, fuzzy-match like the - // rest of gwm; without one, default to the worktree containing the - // CWD — which, unlike `find_fuzzy`, may legitimately be the main - // worktree (syncing trunk is a valid use). + // Resolve the target worktree. With a pattern, fuzzy-match against the + // main repo's worktree list like the rest of gwm. Without one, default + // to the worktree *containing* the CWD — which, unlike `find_fuzzy`, + // may legitimately be the main worktree (syncing trunk is valid). We + // discover that worktree's own workdir (not the CWD basename) so a + // `gwm sync` from a subdirectory still names and targets the worktree + // root rather than the subdir. let (target_path, name) = match pattern { Some(p) => { + let repo = worktree::discover_repo(None)?; let found = worktree::find_fuzzy(&repo, &p)?; (found.path, found.name) } None => { let cwd = std::env::current_dir()?; - let name = cwd + let repo = Repository::discover(&cwd).map_err(|_| GwmError::NotInGitRepo)?; + let workdir = repo.workdir().ok_or(GwmError::NotInGitRepo)?.to_path_buf(); + let name = workdir .file_name() .map(|n| n.to_string_lossy().into_owned()) .unwrap_or_else(|| "worktree".into()); - (cwd, name) + (workdir, name) } }; diff --git a/src/sync.rs b/src/sync.rs index c0c110a..bab1ac3 100644 --- a/src/sync.rs +++ b/src/sync.rs @@ -159,14 +159,34 @@ pub fn sync(start: &Path, strategy: SyncStrategy) -> Result { SyncStrategy::Rebase => run_git(&workdir, &["rebase", &upstream_short]), SyncStrategy::Merge => run_git(&workdir, &["merge", "--no-edit", &upstream_short]), }; - if integrate.is_err() { + if let Err(e) = integrate { + // Distinguish a genuine conflict from any other failure (a failing + // hook, a missing committer identity, a strategy/config error) by + // inspecting the index for conflict stages BEFORE aborting — + // language-independent, unlike grepping git's output. Either way we + // abort so the worktree is left usable. + let conflicted = Repository::discover(start) + .ok() + .and_then(|r| r.index().ok()) + .map(|idx| idx.has_conflicts()) + .unwrap_or(false); let _ = run_git(&workdir, &[strategy.verb(), "--abort"]); + if conflicted { + return Err(GwmError::Other(format!( + "{} onto {} hit conflicts and was aborted; reconcile manually with `git {} {}`", + strategy.verb(), + upstream_short, + strategy.verb(), + upstream_short + ))); + } + // Not a conflict — surface the underlying git failure verbatim so + // the user isn't sent down the wrong recovery path. return Err(GwmError::Other(format!( - "{} onto {} hit conflicts and was aborted; reconcile manually with `git {} {}`", + "git {} onto {} failed and was aborted: {}", strategy.verb(), upstream_short, - strategy.verb(), - upstream_short + e ))); } diff --git a/tests/cli_binary.rs b/tests/cli_binary.rs index 74bf10f..f4a8373 100644 --- a/tests/cli_binary.rs +++ b/tests/cli_binary.rs @@ -376,6 +376,66 @@ fn sync_in_repo_without_upstream_reports_missing_upstream() { .stderr(predicate::str::contains("upstream")); } +#[test] +fn sync_from_subdir_names_the_worktree_root_not_the_subdir() { + // Regression for the Copilot nit on #172: run from a subdirectory, + // the success line must name the worktree root (the dir tracking the + // upstream), not the CWD basename. Set up a bare origin + tracking + // clone so `gwm sync` reaches the "up to date" success print. + use std::process::Command as Git; + + fn git(dir: &Path, args: &[&str]) { + let out = Git::new("git") + .arg("-C") + .arg(dir) + .args(["-c", "commit.gpgsign=false"]) + .args(args) + .env("GIT_AUTHOR_NAME", "t") + .env("GIT_AUTHOR_EMAIL", "t@t") + .env("GIT_COMMITTER_NAME", "t") + .env("GIT_COMMITTER_EMAIL", "t@t") + .output() + .unwrap(); + assert!( + out.status.success(), + "git {:?}: {}", + args, + String::from_utf8_lossy(&out.stderr) + ); + } + + let td = tempfile::TempDir::new().unwrap(); + let origin = td.path().join("origin"); + let wt = td.path().join("my-worktree"); + std::fs::create_dir_all(&origin).unwrap(); + Git::new("git") + .args(["init", "--bare", "-b", "main"]) + .arg(&origin) + .output() + .unwrap(); + Git::new("git").args(["init", "-b", "main"]).arg(&wt).output().unwrap(); + git(&wt, &["config", "user.email", "t@t"]); + git(&wt, &["config", "user.name", "t"]); + std::fs::write(wt.join("file.txt"), "base\n").unwrap(); + git(&wt, &["add", "-A"]); + git(&wt, &["commit", "-m", "init"]); + git(&wt, &["remote", "add", "origin", origin.to_str().unwrap()]); + git(&wt, &["push", "-u", "origin", "main"]); + + let sub = wt.join("src/deep"); + std::fs::create_dir_all(&sub).unwrap(); + + let mut cmd = Command::cargo_bin("gwm").unwrap(); + cmd + .current_dir(&sub) + .arg("sync") + .assert() + .success() + // Names the worktree root dir, not the "deep" subdir we ran from. + .stdout(predicate::str::contains("my-worktree")) + .stdout(predicate::str::contains("deep").not()); +} + #[test] fn labels_push_with_no_declared_labels_is_a_no_op() { // Same fast path as `list`. Push must not call `gh` when there's