Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 9 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ Bundled syntax grammars are compiled into the binary, so removing the checkout d
If you have local edits (including untracked files) and want to review them before committing, run:

```bash
deff --include-uncommitted
deff --only-uncommitted
```

This opens the side-by-side review so you can check exactly what changed in your working tree.
Expand All @@ -38,6 +38,7 @@ This opens the side-by-side review so you can check exactly what changed in your
- `upstream-ahead` strategy (default) to compare local branch changes against its upstream
- `range` strategy for explicit `--base` / `--head` comparison
- Optional `--include-uncommitted` mode to include working tree and untracked files
- `--only-uncommitted` mode to compare working tree and untracked files against `HEAD`
- Side-by-side panes with independent horizontal scroll offsets
- Keyboard and mouse navigation (including wheel + shift-wheel)
- Vim-like motion navigation (`h`/`j`/`k`/`l`, `g`/`G`, `Ctrl+u`/`Ctrl+d`)
Expand All @@ -62,6 +63,7 @@ deff
deff --strategy upstream-ahead
deff --strategy range --base origin/main --head HEAD
deff --strategy range --base origin/main --include-uncommitted
deff --only-uncommitted
deff --theme dark
```

Expand Down Expand Up @@ -104,9 +106,12 @@ Prerequisites:
# explicit range
deff --base origin/main --head HEAD

# include uncommitted + untracked files
deff --base origin/main --include-uncommitted
```
# include uncommitted + untracked files
deff --base origin/main --include-uncommitted

# compare only working tree + untracked files against HEAD
deff --only-uncommitted
```

If your branch has no upstream configured, use the explicit `--base` flow.

Expand Down
76 changes: 76 additions & 0 deletions src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ const DEFAULT_HEAD_REF: &str = "HEAD";
deff
deff --strategy upstream-ahead
deff --include-uncommitted
deff --only-uncommitted
deff --strategy range --base <git-ref> [--head <git-ref>]
deff --strategy range --base <git-ref> --include-uncommitted
deff --theme dark
Expand Down Expand Up @@ -43,6 +44,8 @@ struct Cli {
head: String,
#[arg(long)]
include_uncommitted: bool,
#[arg(long)]
only_uncommitted: bool,
#[arg(long, value_enum, default_value_t = ThemeMode::Auto)]
theme: ThemeMode,
}
Expand All @@ -53,6 +56,7 @@ pub(crate) struct CliOptions {
pub(crate) base_ref: Option<String>,
pub(crate) head_ref: String,
pub(crate) include_uncommitted: bool,
pub(crate) only_uncommitted: bool,
pub(crate) theme_mode: ThemeMode,
}

Expand Down Expand Up @@ -83,6 +87,21 @@ impl TryFrom<Cli> for CliOptions {
bail!("--base can only be used with --strategy range");
}

if value.only_uncommitted {
if strategy_explicitly_set {
bail!("--only-uncommitted cannot be combined with --strategy");
}
if value.base.is_some() {
bail!("--only-uncommitted cannot be combined with --base");
}
if value.head != DEFAULT_HEAD_REF {
bail!("--only-uncommitted cannot be combined with --head");
}
if value.include_uncommitted {
bail!("--only-uncommitted cannot be combined with --include-uncommitted");
}
}

if value.include_uncommitted && value.head != DEFAULT_HEAD_REF {
bail!("--include-uncommitted currently requires --head HEAD");
}
Expand All @@ -92,6 +111,7 @@ impl TryFrom<Cli> for CliOptions {
base_ref: value.base,
head_ref: value.head,
include_uncommitted: value.include_uncommitted,
only_uncommitted: value.only_uncommitted,
theme_mode: value.theme,
})
}
Expand All @@ -101,3 +121,59 @@ pub(crate) fn parse_cli_options() -> Result<CliOptions> {
let cli = Cli::parse();
CliOptions::try_from(cli)
}

#[cfg(test)]
mod tests {
use super::*;

fn base_cli() -> Cli {
Cli {
strategy: None,
base: None,
head: DEFAULT_HEAD_REF.to_string(),
include_uncommitted: false,
only_uncommitted: false,
theme: ThemeMode::Auto,
}
}

#[test]
fn only_uncommitted_sets_flag_on_options() {
let mut cli = base_cli();
cli.only_uncommitted = true;

let options = CliOptions::try_from(cli).expect("cli options should parse");

assert!(options.only_uncommitted);
assert!(!options.include_uncommitted);
}

#[test]
fn only_uncommitted_rejects_strategy() {
let mut cli = base_cli();
cli.only_uncommitted = true;
cli.strategy = Some(StrategyArg::Range);
cli.base = Some("origin/main".to_string());

let error = CliOptions::try_from(cli).expect_err("strategy should be rejected");
assert!(
error
.to_string()
.contains("--only-uncommitted cannot be combined with --strategy")
);
}

#[test]
fn only_uncommitted_rejects_head_override() {
let mut cli = base_cli();
cli.only_uncommitted = true;
cli.head = "HEAD~1".to_string();

let error = CliOptions::try_from(cli).expect_err("head override should be rejected");
assert!(
error
.to_string()
.contains("--only-uncommitted cannot be combined with --head")
);
}
}
29 changes: 29 additions & 0 deletions src/git.rs
Original file line number Diff line number Diff line change
Expand Up @@ -171,10 +171,38 @@ fn resolve_range_comparison(
})
}

fn resolve_only_uncommitted_comparison(repo_root: &Path) -> Result<ResolvedComparison> {
let current_branch = run_git_text(["rev-parse", "--abbrev-ref", "HEAD"], repo_root)?
.trim()
.to_string();
let head_commit = run_git_text(["rev-parse", "HEAD^{commit}"], repo_root)?
.trim()
.to_string();

Ok(ResolvedComparison {
strategy_id: StrategyId::OnlyUncommitted,
base_ref: current_branch.clone(),
head_ref: current_branch.clone(),
base_commit: head_commit.clone(),
head_commit,
summary: format!("{current_branch}..WORKTREE"),
details: vec![
format!("branch: {current_branch}"),
"mode: only-uncommitted".to_string(),
],
ahead_count: None,
includes_uncommitted: true,
})
}

pub(crate) fn resolve_comparison(
repo_root: &Path,
options: &CliOptions,
) -> Result<ResolvedComparison> {
if options.only_uncommitted {
return resolve_only_uncommitted_comparison(repo_root);
}

match options.strategy_id {
StrategyId::Range => {
let base_ref = options
Expand All @@ -186,5 +214,6 @@ pub(crate) fn resolve_comparison(
StrategyId::UpstreamAhead => {
resolve_upstream_ahead_comparison(repo_root, &options.head_ref)
}
StrategyId::OnlyUncommitted => resolve_only_uncommitted_comparison(repo_root),
}
}
2 changes: 2 additions & 0 deletions src/model.rs
Original file line number Diff line number Diff line change
Expand Up @@ -27,13 +27,15 @@ pub(crate) enum StrategyArg {
pub(crate) enum StrategyId {
UpstreamAhead,
Range,
OnlyUncommitted,
}

impl Display for StrategyId {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
StrategyId::UpstreamAhead => write!(f, "upstream-ahead"),
StrategyId::Range => write!(f, "range"),
StrategyId::OnlyUncommitted => write!(f, "only-uncommitted"),
}
}
}
Expand Down
Loading