a73x

580f4b96

Add man pages, format-patch import and a commit browser

a73x   2026-03-21 10:05

Commit message
Add man pages, format-patch import and a commit browser

.github/workflows/ci.yml
Old New
@@ -0,0 +1,21 @@
1 name: CI
2
3 on:
4 push:
5 branches: [main]
6 pull_request:
7 branches: [main]
8
9 env:
10 CARGO_TERM_COLOR: always
11
12 jobs:
13 ci:
14 runs-on: ubuntu-latest
15 steps:
16 - uses: actions/checkout@v4
17 - uses: dtolnay/rust-toolchain@stable
18 with:
19 components: clippy, rustfmt
20 - uses: Swatinem/rust-cache@v2
21 - run: make ci
.github/workflows/release.yml
Old New
@@ -0,0 +1,58 @@
1 name: Release
2
3 on:
4 push:
5 tags: ["v*"]
6
7 permissions:
8 contents: write
9
10 env:
11 CARGO_TERM_COLOR: always
12
13 jobs:
14 build:
15 strategy:
16 matrix:
17 include:
18 - target: x86_64-unknown-linux-gnu
19 os: ubuntu-latest
20 - target: aarch64-unknown-linux-gnu
21 os: ubuntu-latest
22 - target: x86_64-apple-darwin
23 os: macos-latest
24 - target: aarch64-apple-darwin
25 os: macos-latest
26 runs-on: ${{ matrix.os }}
27 steps:
28 - uses: actions/checkout@v4
29 - uses: dtolnay/rust-toolchain@stable
30 with:
31 targets: ${{ matrix.target }}
32 - name: Install cross-compilation tools
33 if: matrix.target == 'aarch64-unknown-linux-gnu'
34 run: |
35 sudo apt-get update
36 sudo apt-get install -y gcc-aarch64-linux-gnu
37 echo "CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER=aarch64-linux-gnu-gcc" >> $GITHUB_ENV
38 - run: cargo build --release --target ${{ matrix.target }}
39 - name: Package binary
40 run: |
41 cd target/${{ matrix.target }}/release
42 tar czf ../../../git-collab-${{ matrix.target }}.tar.gz git-collab
43 - uses: actions/upload-artifact@v4
44 with:
45 name: git-collab-${{ matrix.target }}
46 path: git-collab-${{ matrix.target }}.tar.gz
47
48 release:
49 needs: build
50 runs-on: ubuntu-latest
51 steps:
52 - uses: actions/download-artifact@v4
53 with:
54 merge-multiple: true
55 - uses: softprops/action-gh-release@v2
56 with:
57 files: git-collab-*.tar.gz
58 generate_release_notes: true
.gitignore
Old New
@@ -1 +1,2 @@
1 /target 1 /target
2 /man
CLAUDE.md
Old New
@@ -0,0 +1,35 @@
1 # git-collab Development Guidelines
2
3 Auto-generated from all feature plans. Last updated: 2026-03-21
4
5 ## Active Technologies
6 - Rust 2021 edition + git2 0.19, clap 4 (derive), ed25519-dalek 2, base64 0.22, serde/serde_json 1, dirs 5, thiserror 2 (003-key-trust-allowlist)
7 - Git refs under `.git/refs/collab/`, trusted keys file at `.git/collab/trusted-keys` (plain text, not a git object) (003-key-trust-allowlist)
8 - Rust 2021 edition + ratatui 0.30, crossterm 0.29, git2 0.19 (004-dashboard-filtering)
9 - N/A (ephemeral filter state, no persistence) (004-dashboard-filtering)
10
11 - Rust 2021 edition + git2 0.19, clap 4, serde/serde_json 1, chrono 0.4, thiserror 2. New: `ed25519-dalek`, `rand`, `base64` (001-gpg-event-signing)
12
13 ## Project Structure
14
15 ```text
16 src/
17 tests/
18 ```
19
20 ## Commands
21
22 cargo test [ONLY COMMANDS FOR ACTIVE TECHNOLOGIES][ONLY COMMANDS FOR ACTIVE TECHNOLOGIES] cargo clippy
23
24 ## Code Style
25
26 Rust 2021 edition: Follow standard conventions
27
28 ## Recent Changes
29 - 004-dashboard-filtering: Added Rust 2021 edition + ratatui 0.30, crossterm 0.29, git2 0.19
30 - 003-key-trust-allowlist: Added Rust 2021 edition + git2 0.19, clap 4 (derive), ed25519-dalek 2, base64 0.22, serde/serde_json 1, dirs 5, thiserror 2
31
32 - 001-gpg-event-signing: Added Rust 2021 edition + git2 0.19, clap 4, serde/serde_json 1, chrono 0.4, thiserror 2. New: `ed25519-dalek`, `rand`, `base64`
33
34 <!-- MANUAL ADDITIONS START -->
35 <!-- MANUAL ADDITIONS END -->
Cargo.lock
Old New
@@ -245,6 +245,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
245 checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" 245 checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9"
246 246
247 [[package]] 247 [[package]]
248 name = "clap_mangen"
249 version = "0.2.33"
250 source = "registry+https://github.com/rust-lang/crates.io-index"
251 checksum = "7e30ffc187e2e3aeafcd1c6e2aa416e29739454c0ccaa419226d5ecd181f2d78"
252 dependencies = [
253 "clap",
254 "roff",
255 ]
256
257 [[package]]
248 name = "colorchoice" 258 name = "colorchoice"
249 version = "1.0.5" 259 version = "1.0.5"
250 source = "registry+https://github.com/rust-lang/crates.io-index" 260 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -687,6 +697,7 @@ dependencies = [
687 "base64", 697 "base64",
688 "chrono", 698 "chrono",
689 "clap", 699 "clap",
700 "clap_mangen",
690 "crossterm", 701 "crossterm",
691 "dirs", 702 "dirs",
692 "ed25519-dalek", 703 "ed25519-dalek",
@@ -1600,6 +1611,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
1600 checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" 1611 checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a"
1601 1612
1602 [[package]] 1613 [[package]]
1614 name = "roff"
1615 version = "1.1.0"
1616 source = "registry+https://github.com/rust-lang/crates.io-index"
1617 checksum = "dbf2048e0e979efb2ca7b91c4f1a8d77c91853e9b987c94c555668a8994915ad"
1618
1619 [[package]]
1603 name = "rustc_version" 1620 name = "rustc_version"
1604 version = "0.4.1" 1621 version = "0.4.1"
1605 source = "registry+https://github.com/rust-lang/crates.io-index" 1622 source = "registry+https://github.com/rust-lang/crates.io-index"
Cargo.toml
Old New
@@ -17,5 +17,9 @@ rand_core = { version = "0.6", features = ["getrandom"] }
17 base64 = "0.22" 17 base64 = "0.22"
18 dirs = "5" 18 dirs = "5"
19 19
20 [build-dependencies]
21 clap = { version = "4", features = ["derive"] }
22 clap_mangen = "0.2"
23
20 [dev-dependencies] 24 [dev-dependencies]
21 tempfile = "3" 25 tempfile = "3"
Makefile
Old New
@@ -30,15 +30,31 @@ ci: lint test build
30 30
31 # --- Install --- 31 # --- Install ---
32 install: PROFILE := release 32 install: PROFILE := release
33 install: build 33 install: build install-man
34 install -Dm755 target/release/$(BIN) $(PREFIX)/bin/$(BIN) 34 install -Dm755 target/release/$(BIN) $(PREFIX)/bin/$(BIN)
35 35
36 uninstall: 36 uninstall:
37 rm -f $(PREFIX)/bin/$(BIN) 37 rm -f $(PREFIX)/bin/$(BIN)
38 38
39 clean: 39 clean: clean-man
40 $(CARGO) clean 40 $(CARGO) clean
41 41
42 # --- Man pages ---
43 MAN_DIR := man/man1
44
45 .PHONY: man install-man clean-man
46
47 man:
48 MAN_OUT_DIR=$(MAN_DIR) $(CARGO) build
49 @echo "Man pages written to $(MAN_DIR)/"
50
51 install-man: man
52 install -d $(PREFIX)/share/man/man1
53 install -m644 $(MAN_DIR)/*.1 $(PREFIX)/share/man/man1/
54
55 clean-man:
56 rm -rf man/
57
42 # --- Dev helpers --- 58 # --- Dev helpers ---
43 .PHONY: run dev dashboard 59 .PHONY: run dev dashboard
44 60
build.rs
Old New
@@ -0,0 +1,37 @@
1 use std::env;
2 use std::fs;
3 use std::path::PathBuf;
4
5 include!("src/cli.rs");
6
7 fn main() {
8 let out = PathBuf::from(
9 env::var("MAN_OUT_DIR").unwrap_or_else(|_| {
10 env::var("OUT_DIR").expect("OUT_DIR not set")
11 }),
12 );
13
14 let cmd = <Cli as clap::CommandFactory>::command();
15 generate_manpages(&cmd, &out);
16 }
17
18 fn generate_manpages(cmd: &clap::Command, out: &PathBuf) {
19 let man = clap_mangen::Man::new(cmd.clone());
20 let name = cmd.get_name().to_string();
21 let mut buf = Vec::new();
22 man.render(&mut buf).expect("failed to render man page");
23 fs::create_dir_all(out).expect("failed to create man output dir");
24 fs::write(out.join(format!("{name}.1")), buf)
25 .expect("failed to write man page");
26
27 for sub in cmd.get_subcommands() {
28 if sub.is_hide_set() {
29 continue;
30 }
31 let sub_name = format!("{}-{}", cmd.get_name(), sub.get_name());
32 // Leak is fine: build scripts are short-lived processes. clap requires 'static str for name().
33 let sub_name: &'static str = Box::leak(sub_name.into_boxed_str());
34 let sub_cmd = sub.clone().name(sub_name);
35 generate_manpages(&sub_cmd, out);
36 }
37 }
src/cli.rs
Old New
@@ -165,9 +165,9 @@ pub enum PatchCmd {
165 /// Base branch ref 165 /// Base branch ref
166 #[arg(long, default_value = "main")] 166 #[arg(long, default_value = "main")]
167 base: String, 167 base: String,
168 /// Head commit to review 168 /// Head commit to review (defaults to HEAD)
169 #[arg(long)] 169 #[arg(long)]
170 head: String, 170 head: Option<String>,
171 /// Issue ID this patch fixes (auto-closes on merge) 171 /// Issue ID this patch fixes (auto-closes on merge)
172 #[arg(long)] 172 #[arg(long)]
173 fixes: Option<String>, 173 fixes: Option<String>,
@@ -217,9 +217,9 @@ pub enum PatchCmd {
217 Revise { 217 Revise {
218 /// Patch ID (prefix match) 218 /// Patch ID (prefix match)
219 id: String, 219 id: String,
220 /// New head commit 220 /// New head commit (defaults to HEAD)
221 #[arg(long)] 221 #[arg(long)]
222 head: String, 222 head: Option<String>,
223 /// Updated description 223 /// Updated description
224 #[arg(short, long)] 224 #[arg(short, long)]
225 body: Option<String>, 225 body: Option<String>,
@@ -237,4 +237,9 @@ pub enum PatchCmd {
237 #[arg(short, long)] 237 #[arg(short, long)]
238 reason: Option<String>, 238 reason: Option<String>,
239 }, 239 },
240 /// Import patches from format-patch files
241 Import {
242 /// One or more .patch files to import
243 files: Vec<std::path::PathBuf>,
244 },
240 } 245 }
src/editor.rs
Old New
@@ -0,0 +1,275 @@
1 use std::process::Command;
2
3 use crate::error::Error;
4
5 /// Resolve the user's preferred editor by checking $VISUAL, then $EDITOR.
6 /// Returns `None` if neither is set or both are empty.
7 pub fn resolve_editor() -> Option<String> {
8 resolve_editor_from(
9 std::env::var("VISUAL").ok().as_deref(),
10 std::env::var("EDITOR").ok().as_deref(),
11 )
12 }
13
14 /// Inner helper: resolve editor from explicit values (testable without env mutation).
15 fn resolve_editor_from(visual: Option<&str>, editor: Option<&str>) -> Option<String> {
16 for val in [visual, editor].into_iter().flatten() {
17 let trimmed = val.trim();
18 if !trimmed.is_empty() {
19 return Some(trimmed.to_string());
20 }
21 }
22 None
23 }
24
25 /// Launch the editor at a specific file and line number.
26 ///
27 /// The editor string is split on whitespace to support editors like `code --wait`.
28 /// The command is invoked as: `<editor...> +{line} {file}`.
29 pub fn open_editor_at(file: &str, line: u32) -> Result<(), Error> {
30 let editor_str = resolve_editor().ok_or_else(|| {
31 Error::Cmd("No editor configured. Set $EDITOR or $VISUAL.".to_string())
32 })?;
33
34 open_editor_at_with(file, line, &editor_str)
35 }
36
37 /// Inner helper: launch an editor command at a specific file and line.
38 /// Separated from `open_editor_at` so tests can pass an explicit editor string
39 /// without mutating environment variables.
40 fn open_editor_at_with(file: &str, line: u32, editor_str: &str) -> Result<(), Error> {
41 if !std::path::Path::new(file).exists() {
42 return Err(Error::Cmd(format!("File not found: {}", file)));
43 }
44
45 let parts: Vec<&str> = editor_str.split_whitespace().collect();
46 if parts.is_empty() {
47 return Err(Error::Cmd("Editor command is empty".to_string()));
48 }
49
50 let program = parts[0];
51 let extra_args = &parts[1..];
52
53 let status = Command::new(program)
54 .args(extra_args)
55 .arg(format!("+{}", line))
56 .arg(file)
57 .status()
58 .map_err(|e| Error::Cmd(format!("Failed to launch editor '{}': {}", program, e)))?;
59
60 if !status.success() {
61 let code = status.code().unwrap_or(-1);
62 return Err(Error::Cmd(format!("Editor exited with status: {}", code)));
63 }
64
65 Ok(())
66 }
67
68 /// Given a list of comment positions `(file, line, rendered_row)` and the
69 /// current scroll position, find the comment whose rendered row is closest to
70 /// and at or above the scroll position. Returns `(file, line)` if found.
71 ///
72 /// The `rendered_position` (third tuple element) is the row index within the
73 /// rendered diff text where this inline comment appears.
74 pub fn find_comment_at_scroll(
75 comments: &[(String, u32, usize)],
76 scroll_pos: u16,
77 ) -> Option<(String, u32)> {
78 let scroll = scroll_pos as usize;
79 let mut best: Option<&(String, u32, usize)> = None;
80
81 for entry in comments {
82 let rendered = entry.2;
83 if rendered <= scroll {
84 match best {
85 Some(prev) if rendered > prev.2 => best = Some(entry),
86 None => best = Some(entry),
87 _ => {}
88 }
89 }
90 }
91
92 best.map(|e| (e.0.clone(), e.1))
93 }
94
95 #[cfg(test)]
96 mod tests {
97 use super::*;
98 use std::io::Write;
99
100 // ---- resolve_editor tests (pure, no env mutation) ----
101
102 #[test]
103 fn test_resolve_editor_visual_takes_precedence() {
104 let result = resolve_editor_from(Some("nvim"), Some("vi"));
105 assert_eq!(result, Some("nvim".to_string()));
106 }
107
108 #[test]
109 fn test_resolve_editor_falls_back_to_editor() {
110 let result = resolve_editor_from(None, Some("nano"));
111 assert_eq!(result, Some("nano".to_string()));
112 }
113
114 #[test]
115 fn test_resolve_editor_none_when_unset() {
116 let result = resolve_editor_from(None, None);
117 assert_eq!(result, None);
118 }
119
120 #[test]
121 fn test_resolve_editor_skips_empty_visual() {
122 let result = resolve_editor_from(Some(" "), Some("vim"));
123 assert_eq!(result, Some("vim".to_string()));
124 }
125
126 #[test]
127 fn test_resolve_editor_both_empty() {
128 let result = resolve_editor_from(Some(""), Some(""));
129 assert_eq!(result, None);
130 }
131
132 #[test]
133 fn test_resolve_editor_trims_whitespace() {
134 let result = resolve_editor_from(None, Some(" vim "));
135 assert_eq!(result, Some("vim".to_string()));
136 }
137
138 // ---- find_comment_at_scroll tests ----
139
140 #[test]
141 fn test_find_comment_at_scroll_empty() {
142 let comments: Vec<(String, u32, usize)> = vec![];
143 assert_eq!(find_comment_at_scroll(&comments, 10), None);
144 }
145
146 #[test]
147 fn test_find_comment_at_scroll_exact_match() {
148 let comments = vec![
149 ("src/main.rs".to_string(), 42, 10),
150 ("src/lib.rs".to_string(), 15, 20),
151 ];
152 let result = find_comment_at_scroll(&comments, 10);
153 assert_eq!(result, Some(("src/main.rs".to_string(), 42)));
154 }
155
156 #[test]
157 fn test_find_comment_at_scroll_picks_closest_above() {
158 let comments = vec![
159 ("a.rs".to_string(), 1, 5),
160 ("b.rs".to_string(), 2, 15),
161 ("c.rs".to_string(), 3, 25),
162 ];
163 // Scroll is at 20, closest at-or-above is rendered_pos=15
164 let result = find_comment_at_scroll(&comments, 20);
165 assert_eq!(result, Some(("b.rs".to_string(), 2)));
166 }
167
168 #[test]
169 fn test_find_comment_at_scroll_all_below() {
170 let comments = vec![
171 ("a.rs".to_string(), 1, 30),
172 ("b.rs".to_string(), 2, 40),
173 ];
174 // Scroll at 10, all comments are below
175 let result = find_comment_at_scroll(&comments, 10);
176 assert_eq!(result, None);
177 }
178
179 #[test]
180 fn test_find_comment_at_scroll_first_wins_on_tie() {
181 // Two comments at same rendered position -- first in list wins
182 // because our > comparison doesn't replace when equal.
183 let comments = vec![
184 ("a.rs".to_string(), 1, 10),
185 ("b.rs".to_string(), 2, 10),
186 ];
187 let result = find_comment_at_scroll(&comments, 10);
188 assert_eq!(result, Some(("a.rs".to_string(), 1)));
189 }
190
191 #[test]
192 fn test_find_comment_at_scroll_scroll_at_zero() {
193 let comments = vec![
194 ("a.rs".to_string(), 1, 0),
195 ("b.rs".to_string(), 2, 5),
196 ];
197 let result = find_comment_at_scroll(&comments, 0);
198 assert_eq!(result, Some(("a.rs".to_string(), 1)));
199 }
200
201 #[test]
202 fn test_find_comment_at_scroll_single_comment_above() {
203 let comments = vec![("only.rs".to_string(), 99, 3)];
204 let result = find_comment_at_scroll(&comments, 100);
205 assert_eq!(result, Some(("only.rs".to_string(), 99)));
206 }
207
208 // ---- open_editor_at tests (using inner helper, no env mutation) ----
209
210 #[test]
211 fn test_open_editor_at_success_with_true() {
212 let mut tmp = tempfile::NamedTempFile::new().unwrap();
213 writeln!(tmp, "hello").unwrap();
214 let path = tmp.path().to_str().unwrap().to_string();
215
216 let result = open_editor_at_with(&path, 1, "true");
217 assert!(result.is_ok(), "Expected Ok, got {:?}", result);
218 }
219
220 #[test]
221 fn test_open_editor_at_file_not_found() {
222 let result =
223 open_editor_at_with("/tmp/nonexistent_file_for_test_abc123xyz.txt", 1, "true");
224 assert!(result.is_err());
225 let err_msg = format!("{}", result.unwrap_err());
226 assert!(
227 err_msg.contains("File not found"),
228 "Unexpected error: {}",
229 err_msg
230 );
231 }
232
233 #[test]
234 fn test_open_editor_at_nonzero_exit() {
235 let mut tmp = tempfile::NamedTempFile::new().unwrap();
236 writeln!(tmp, "hello").unwrap();
237 let path = tmp.path().to_str().unwrap().to_string();
238
239 let result = open_editor_at_with(&path, 1, "false");
240 assert!(result.is_err());
241 let err_msg = format!("{}", result.unwrap_err());
242 assert!(
243 err_msg.contains("Editor exited with status"),
244 "Unexpected error: {}",
245 err_msg
246 );
247 }
248
249 #[test]
250 fn test_open_editor_at_with_args_in_editor_string() {
251 // `true` ignores all arguments, so "true --wait" should succeed
252 let mut tmp = tempfile::NamedTempFile::new().unwrap();
253 writeln!(tmp, "hello").unwrap();
254 let path = tmp.path().to_str().unwrap().to_string();
255
256 let result = open_editor_at_with(&path, 42, "true --wait");
257 assert!(result.is_ok(), "Expected Ok, got {:?}", result);
258 }
259
260 #[test]
261 fn test_open_editor_at_bad_command() {
262 let mut tmp = tempfile::NamedTempFile::new().unwrap();
263 writeln!(tmp, "hello").unwrap();
264 let path = tmp.path().to_str().unwrap().to_string();
265
266 let result = open_editor_at_with(&path, 1, "nonexistent_editor_binary_xyz");
267 assert!(result.is_err());
268 let err_msg = format!("{}", result.unwrap_err());
269 assert!(
270 err_msg.contains("Failed to launch editor"),
271 "Unexpected error: {}",
272 err_msg
273 );
274 }
275 }
src/error.rs
Old New
@@ -25,4 +25,10 @@ pub enum Error {
25 25
26 #[error("untrusted key: {0}")] 26 #[error("untrusted key: {0}")]
27 UntrustedKey(String), 27 UntrustedKey(String),
28
29 #[error("malformed patch: {0}")]
30 MalformedPatch(String),
31
32 #[error("patch apply failed: {0}")]
33 PatchApplyFailed(String),
28 } 34 }
src/lib.rs
Old New
@@ -1,5 +1,6 @@
1 pub mod cli; 1 pub mod cli;
2 pub mod dag; 2 pub mod dag;
3 pub mod editor;
3 pub mod error; 4 pub mod error;
4 pub mod event; 5 pub mod event;
5 pub mod identity; 6 pub mod identity;
@@ -133,6 +134,10 @@ pub fn run(cli: cli::Cli, repo: &Repository) -> Result<(), error::Error> {
133 head, 134 head,
134 fixes, 135 fixes,
135 } => { 136 } => {
137 let head = match head {
138 Some(h) => h,
139 None => repo.head()?.peel_to_commit()?.id().to_string(),
140 };
136 let id = patch::create(repo, &title, &body, &base, &head, fixes.as_deref())?; 141 let id = patch::create(repo, &title, &body, &base, &head, fixes.as_deref())?;
137 println!("Created patch {:.8}", id); 142 println!("Created patch {:.8}", id);
138 Ok(()) 143 Ok(())
@@ -236,6 +241,10 @@ pub fn run(cli: cli::Cli, repo: &Repository) -> Result<(), error::Error> {
236 Ok(()) 241 Ok(())
237 } 242 }
238 PatchCmd::Revise { id, head, body } => { 243 PatchCmd::Revise { id, head, body } => {
244 let head = match head {
245 Some(h) => h,
246 None => repo.head()?.peel_to_commit()?.id().to_string(),
247 };
239 patch::revise(repo, &id, &head, body.as_deref())?; 248 patch::revise(repo, &id, &head, body.as_deref())?;
240 println!("Patch revised."); 249 println!("Patch revised.");
241 Ok(()) 250 Ok(())
@@ -250,6 +259,13 @@ pub fn run(cli: cli::Cli, repo: &Repository) -> Result<(), error::Error> {
250 println!("Patch closed."); 259 println!("Patch closed.");
251 Ok(()) 260 Ok(())
252 } 261 }
262 PatchCmd::Import { files } => {
263 let ids = patch::import_series(repo, &files)?;
264 for id in &ids {
265 println!("Imported patch {:.8}", id);
266 }
267 Ok(())
268 }
253 }, 269 },
254 Commands::Dashboard => tui::run(repo), 270 Commands::Dashboard => tui::run(repo),
255 Commands::Sync { remote } => sync::sync(repo, &remote), 271 Commands::Sync { remote } => sync::sync(repo, &remote),
src/patch.rs
Old New
@@ -1,4 +1,6 @@
1 use git2::{DiffFormat, Repository}; 1 use std::path::Path;
2
3 use git2::{Diff, DiffFormat, Repository};
2 4
3 use crate::dag; 5 use crate::dag;
4 use crate::error::Error; 6 use crate::error::Error;
@@ -281,3 +283,187 @@ pub fn close(
281 dag::append_event(repo, &ref_name, &event, &sk)?; 283 dag::append_event(repo, &ref_name, &event, &sk)?;
282 Ok(()) 284 Ok(())
283 } 285 }
286
287 // ---------------------------------------------------------------------------
288 // Patch import from format-patch files
289 // ---------------------------------------------------------------------------
290
291 /// Parsed metadata from a git format-patch mbox header.
292 struct PatchHeader {
293 subject: String,
294 body: String,
295 }
296
297 /// Parse a format-patch file into its mbox header metadata and the raw diff portion.
298 fn parse_format_patch(content: &str) -> Result<(PatchHeader, String), Error> {
299 // Find the "---" separator that divides the commit message from the diffstat/diff.
300 // The diff starts at the first line matching "diff --git".
301 let diff_start = content
302 .find("\ndiff --git ")
303 .map(|i| i + 1) // skip the leading newline
304 .ok_or_else(|| Error::MalformedPatch("no 'diff --git' found in patch file".to_string()))?;
305
306 let header_section = &content[..diff_start];
307 let diff_section = &content[diff_start..];
308
309 // Extract Subject line
310 let subject_line = header_section
311 .lines()
312 .find(|l| l.starts_with("Subject:"))
313 .ok_or_else(|| Error::MalformedPatch("no Subject header found".to_string()))?;
314
315 // Strip "Subject: " prefix and optional "[PATCH] " or "[PATCH n/m] " prefix
316 let subject = subject_line
317 .strip_prefix("Subject:")
318 .unwrap()
319 .trim();
320 let subject = if let Some(rest) = subject.strip_prefix("[PATCH") {
321 // Skip to the "] " closing bracket
322 if let Some(idx) = rest.find("] ") {
323 rest[idx + 2..].to_string()
324 } else {
325 subject.to_string()
326 }
327 } else {
328 subject.to_string()
329 };
330
331 // Extract body: everything between the blank line after headers and the "---" separator
332 let body = extract_body(header_section);
333
334 // Trim trailing "-- \n2.xx.x\n" signature from diff
335 let diff_clean = trim_patch_signature(diff_section);
336
337 Ok((PatchHeader { subject, body }, diff_clean))
338 }
339
340 /// Extract the commit message body from the header section.
341 /// The body is between the first blank line after headers and the "---" line.
342 fn extract_body(header_section: &str) -> String {
343 let lines: Vec<&str> = header_section.lines().collect();
344 let mut body_start = None;
345 let mut body_end = None;
346
347 // Find first blank line (end of mail headers)
348 for (i, line) in lines.iter().enumerate() {
349 if line.is_empty() && body_start.is_none() {
350 body_start = Some(i + 1);
351 }
352 }
353
354 // Find the "---" separator line (start of diffstat)
355 for (i, line) in lines.iter().enumerate().rev() {
356 if *line == "---" {
357 body_end = Some(i);
358 break;
359 }
360 }
361
362 match (body_start, body_end) {
363 (Some(start), Some(end)) if start < end => {
364 lines[start..end].join("\n").trim().to_string()
365 }
366 (Some(start), None) => {
367 // No "---" separator, take everything after headers
368 lines[start..].join("\n").trim().to_string()
369 }
370 _ => String::new(),
371 }
372 }
373
374 /// Remove trailing git patch signature ("-- \n2.xx.x\n") from diff content.
375 fn trim_patch_signature(diff: &str) -> String {
376 if let Some(idx) = diff.rfind("\n-- \n") {
377 diff[..idx + 1].to_string() // keep the trailing newline
378 } else {
379 diff.to_string()
380 }
381 }
382
383 /// Import a single format-patch file, creating a commit and DAG entry.
384 /// Returns the patch ID.
385 pub fn import(repo: &Repository, patch_path: &Path) -> Result<String, Error> {
386 let content = std::fs::read_to_string(patch_path)?;
387 let (header, diff_text) = parse_format_patch(&content)?;
388
389 // Parse the diff with git2
390 let diff = Diff::from_buffer(diff_text.as_bytes())
391 .map_err(|e| Error::MalformedPatch(format!("invalid diff: {}", e)))?;
392
393 // Get the base (HEAD) commit and its tree
394 let head_ref = repo
395 .head()
396 .map_err(|e| Error::PatchApplyFailed(format!("cannot resolve HEAD: {}", e)))?;
397 let head_commit = head_ref
398 .peel_to_commit()
399 .map_err(|e| Error::PatchApplyFailed(format!("HEAD is not a commit: {}", e)))?;
400 let base_tree = head_commit.tree()?;
401
402 // Apply the diff to the base tree in-memory
403 let new_index = repo
404 .apply_to_tree(&base_tree, &diff, None)
405 .map_err(|e| Error::PatchApplyFailed(format!("apply failed: {}", e)))?;
406
407 // Write the index to a tree
408 let tree_oid = {
409 let mut idx = new_index;
410 idx.write_tree_to(repo)?
411 };
412 let new_tree = repo.find_tree(tree_oid)?;
413
414 // Create a commit on a detached ref (no branch update)
415 let author = get_author(repo)?;
416 let sig = crate::identity::author_signature(&author)?;
417 let commit_msg = format!("imported: {}", header.subject);
418 let commit_oid = repo.commit(
419 None, // don't update any ref
420 &sig,
421 &sig,
422 &commit_msg,
423 &new_tree,
424 &[&head_commit],
425 )?;
426
427 // Determine the base branch name from HEAD
428 let base_ref = repo
429 .head()?
430 .shorthand()
431 .unwrap_or("main")
432 .to_string();
433
434 // Create a DAG entry using the existing patch create infrastructure
435 let id = create(
436 repo,
437 &header.subject,
438 &header.body,
439 &base_ref,
440 &commit_oid.to_string(),
441 None,
442 )?;
443
444 Ok(id)
445 }
446
447 /// Import a series of format-patch files. If any fails, rolls back all
448 /// previously imported patches from this series.
449 pub fn import_series(repo: &Repository, files: &[impl AsRef<Path>]) -> Result<Vec<String>, Error> {
450 let mut imported_ids: Vec<String> = Vec::new();
451
452 for file in files {
453 match import(repo, file.as_ref()) {
454 Ok(id) => imported_ids.push(id),
455 Err(e) => {
456 // Rollback: delete all refs created in this series
457 for id in &imported_ids {
458 let ref_name = format!("refs/collab/patches/{}", id);
459 if let Ok(mut reference) = repo.find_reference(&ref_name) {
460 let _ = reference.delete();
461 }
462 }
463 return Err(e);
464 }
465 }
466 }
467
468 Ok(imported_ids)
469 }
src/trust.rs
Old New
@@ -134,19 +134,19 @@ pub fn remove_trusted_key(repo: &Repository, pubkey: &str) -> Result<TrustedKey,
134 let policy = load_trust_policy(repo)?; 134 let policy = load_trust_policy(repo)?;
135 match policy { 135 match policy {
136 TrustPolicy::Unconfigured => { 136 TrustPolicy::Unconfigured => {
137 return Err(Error::Cmd(format!( 137 Err(Error::Cmd(format!(
138 "key {} is not in the trusted keys list", 138 "key {} is not in the trusted keys list",
139 pubkey 139 pubkey
140 ))); 140 )))
141 } 141 }
142 TrustPolicy::Configured(keys) => { 142 TrustPolicy::Configured(keys) => {
143 let removed = keys.iter().find(|k| k.pubkey == pubkey).cloned(); 143 let removed = keys.iter().find(|k| k.pubkey == pubkey).cloned();
144 match removed { 144 match removed {
145 None => { 145 None => {
146 return Err(Error::Cmd(format!( 146 Err(Error::Cmd(format!(
147 "key {} is not in the trusted keys list", 147 "key {} is not in the trusted keys list",
148 pubkey 148 pubkey
149 ))); 149 )))
150 } 150 }
151 Some(removed_key) => { 151 Some(removed_key) => {
152 // Rewrite file without the removed key 152 // Rewrite file without the removed key
src/tui.rs
Old New
@@ -5,31 +5,42 @@ use std::time::Duration;
5 use crossterm::event::{self, Event, KeyCode, KeyModifiers}; 5 use crossterm::event::{self, Event, KeyCode, KeyModifiers};
6 use crossterm::terminal::{self, EnterAlternateScreen, LeaveAlternateScreen}; 6 use crossterm::terminal::{self, EnterAlternateScreen, LeaveAlternateScreen};
7 use crossterm::ExecutableCommand; 7 use crossterm::ExecutableCommand;
8 use git2::Repository; 8 use git2::{Oid, Repository};
9 use ratatui::prelude::*; 9 use ratatui::prelude::*;
10 use ratatui::widgets::{Block, Borders, List, ListItem, ListState, Paragraph, Tabs, Wrap}; 10 use ratatui::widgets::{Block, Borders, List, ListItem, ListState, Paragraph, Tabs, Wrap};
11 11
12 use crate::error::Error; 12 use crate::error::Error;
13 use crate::event::Action;
13 use crate::issue as issue_mod; 14 use crate::issue as issue_mod;
14 use crate::patch as patch_mod; 15 use crate::patch as patch_mod;
15 use crate::state::{self, IssueState, IssueStatus, PatchState, PatchStatus}; 16 use crate::state::{self, IssueState, IssueStatus, PatchState, PatchStatus};
16 17
17 #[derive(PartialEq)] 18 #[derive(Debug, PartialEq)]
18 enum Pane { 19 enum Pane {
19 ItemList, 20 ItemList,
20 Detail, 21 Detail,
21 } 22 }
22 23
23 #[derive(PartialEq, Clone, Copy)] 24 #[derive(Debug, PartialEq, Clone, Copy)]
24 enum Tab { 25 enum Tab {
25 Issues, 26 Issues,
26 Patches, 27 Patches,
27 } 28 }
28 29
29 #[derive(PartialEq)] 30 #[derive(Debug, PartialEq)]
30 enum ViewMode { 31 enum ViewMode {
31 Details, 32 Details,
32 Diff, 33 Diff,
34 CommitList,
35 CommitDetail,
36 }
37
38 #[derive(Debug, PartialEq)]
39 enum KeyAction {
40 Continue,
41 Quit,
42 Reload,
43 OpenCommitBrowser,
33 } 44 }
34 45
35 #[derive(Debug, PartialEq, Clone, Copy)] 46 #[derive(Debug, PartialEq, Clone, Copy)]
@@ -80,6 +91,8 @@ struct App {
80 input_buf: String, 91 input_buf: String,
81 create_title: String, 92 create_title: String,
82 status_msg: Option<String>, 93 status_msg: Option<String>,
94 event_history: Vec<(Oid, crate::event::Event)>,
95 event_list_state: ListState,
83 } 96 }
84 97
85 impl App { 98 impl App {
@@ -103,6 +116,8 @@ impl App {
103 input_buf: String::new(), 116 input_buf: String::new(),
104 create_title: String::new(), 117 create_title: String::new(),
105 status_msg: None, 118 status_msg: None,
119 event_history: Vec::new(),
120 event_list_state: ListState::default(),
106 } 121 }
107 } 122 }
108 123
@@ -215,7 +230,7 @@ impl App {
215 } 230 }
216 } 231 }
217 } 232 }
218 return false; 233 false
219 } 234 }
220 Tab::Patches => { 235 Tab::Patches => {
221 // From a patch, jump to the linked issue (fixes field) 236 // From a patch, jump to the linked issue (fixes field)
@@ -243,11 +258,188 @@ impl App {
243 } 258 }
244 } 259 }
245 } 260 }
246 return false; 261 false
262 }
263 }
264 }
265
266 fn handle_key(&mut self, code: KeyCode, modifiers: KeyModifiers) -> KeyAction {
267 // Handle CommitDetail mode first
268 if self.mode == ViewMode::CommitDetail {
269 match code {
270 KeyCode::Esc => {
271 self.mode = ViewMode::CommitList;
272 self.scroll = 0;
273 return KeyAction::Continue;
274 }
275 KeyCode::Char('q') => return KeyAction::Quit,
276 KeyCode::Char('c') if modifiers.contains(KeyModifiers::CONTROL) => {
277 return KeyAction::Quit;
278 }
279 KeyCode::Char('j') | KeyCode::Down => {
280 self.scroll = self.scroll.saturating_add(1);
281 return KeyAction::Continue;
282 }
283 KeyCode::Char('k') | KeyCode::Up => {
284 self.scroll = self.scroll.saturating_sub(1);
285 return KeyAction::Continue;
286 }
287 KeyCode::PageDown => {
288 self.scroll = self.scroll.saturating_add(20);
289 return KeyAction::Continue;
290 }
291 KeyCode::PageUp => {
292 self.scroll = self.scroll.saturating_sub(20);
293 return KeyAction::Continue;
294 }
295 _ => return KeyAction::Continue,
296 }
297 }
298
299 // Handle CommitList mode
300 if self.mode == ViewMode::CommitList {
301 match code {
302 KeyCode::Esc => {
303 self.event_history.clear();
304 self.event_list_state = ListState::default();
305 self.mode = ViewMode::Details;
306 self.scroll = 0;
307 return KeyAction::Continue;
308 }
309 KeyCode::Char('q') => return KeyAction::Quit,
310 KeyCode::Char('c') if modifiers.contains(KeyModifiers::CONTROL) => {
311 return KeyAction::Quit;
312 }
313 KeyCode::Char('j') | KeyCode::Down => {
314 let len = self.event_history.len();
315 if len > 0 {
316 let current = self.event_list_state.selected().unwrap_or(0);
317 let new = (current + 1).min(len - 1);
318 self.event_list_state.select(Some(new));
319 }
320 return KeyAction::Continue;
321 }
322 KeyCode::Char('k') | KeyCode::Up => {
323 if !self.event_history.is_empty() {
324 let current = self.event_list_state.selected().unwrap_or(0);
325 let new = current.saturating_sub(1);
326 self.event_list_state.select(Some(new));
327 }
328 return KeyAction::Continue;
329 }
330 KeyCode::Enter => {
331 if self.event_list_state.selected().is_some() {
332 self.mode = ViewMode::CommitDetail;
333 self.scroll = 0;
334 }
335 return KeyAction::Continue;
336 }
337 _ => return KeyAction::Continue,
338 }
339 }
340
341 // Normal Details/Diff mode handling
342 match code {
343 KeyCode::Char('q') | KeyCode::Esc => KeyAction::Quit,
344 KeyCode::Char('c') if modifiers.contains(KeyModifiers::CONTROL) => KeyAction::Quit,
345 KeyCode::Char('c') => {
346 // Open commit browser: only when in detail pane with an item selected
347 if self.pane == Pane::Detail && self.list_state.selected().is_some() {
348 KeyAction::OpenCommitBrowser
349 } else {
350 KeyAction::Continue
351 }
352 }
353 KeyCode::Char('1') => {
354 self.switch_tab(Tab::Issues);
355 KeyAction::Continue
356 }
357 KeyCode::Char('2') => {
358 self.switch_tab(Tab::Patches);
359 KeyAction::Continue
360 }
361 KeyCode::Char('j') | KeyCode::Down => {
362 if self.pane == Pane::ItemList {
363 self.move_selection(1);
364 } else {
365 self.scroll = self.scroll.saturating_add(1);
366 }
367 KeyAction::Continue
368 }
369 KeyCode::Char('k') | KeyCode::Up => {
370 if self.pane == Pane::ItemList {
371 self.move_selection(-1);
372 } else {
373 self.scroll = self.scroll.saturating_sub(1);
374 }
375 KeyAction::Continue
376 }
377 KeyCode::PageDown => {
378 self.scroll = self.scroll.saturating_add(20);
379 KeyAction::Continue
380 }
381 KeyCode::PageUp => {
382 self.scroll = self.scroll.saturating_sub(20);
383 KeyAction::Continue
384 }
385 KeyCode::Tab | KeyCode::Enter => {
386 self.pane = match self.pane {
387 Pane::ItemList => Pane::Detail,
388 Pane::Detail => Pane::ItemList,
389 };
390 KeyAction::Continue
391 }
392 KeyCode::Char('d') => {
393 if self.tab == Tab::Patches {
394 match self.mode {
395 ViewMode::Details => {
396 self.mode = ViewMode::Diff;
397 self.scroll = 0;
398 }
399 ViewMode::Diff => {
400 self.mode = ViewMode::Details;
401 self.scroll = 0;
402 }
403 _ => {}
404 }
405 }
406 KeyAction::Continue
407 }
408 KeyCode::Char('a') => {
409 self.status_filter = self.status_filter.next();
410 let count = self.visible_count();
411 self.list_state
412 .select(if count > 0 { Some(0) } else { None });
413 KeyAction::Continue
414 }
415 KeyCode::Char('r') => KeyAction::Reload,
416 _ => KeyAction::Continue,
417 }
418 }
419
420 fn selected_item_id(&self) -> Option<String> {
421 let idx = self.list_state.selected()?;
422 match self.tab {
423 Tab::Issues => {
424 let visible = self.visible_issues();
425 visible.get(idx).map(|i| i.id.clone())
426 }
427 Tab::Patches => {
428 let visible = self.visible_patches();
429 visible.get(idx).map(|p| p.id.clone())
247 } 430 }
248 } 431 }
249 } 432 }
250 433
434 fn selected_ref_name(&self) -> Option<String> {
435 let id = self.selected_item_id()?;
436 let prefix = match self.tab {
437 Tab::Issues => "refs/collab/issues",
438 Tab::Patches => "refs/collab/patches",
439 };
440 Some(format!("{}/{}", prefix, id))
441 }
442
251 fn reload(&mut self, repo: &Repository) { 443 fn reload(&mut self, repo: &Repository) {
252 if let Ok(issues) = state::list_issues(repo) { 444 if let Ok(issues) = state::list_issues(repo) {
253 self.issues = issues; 445 self.issues = issues;
@@ -268,6 +460,115 @@ impl App {
268 } 460 }
269 } 461 }
270 462
463 fn action_type_label(action: &Action) -> &str {
464 match action {
465 Action::IssueOpen { .. } => "Issue Open",
466 Action::IssueComment { .. } => "Issue Comment",
467 Action::IssueClose { .. } => "Issue Close",
468 Action::IssueReopen => "Issue Reopen",
469 Action::PatchCreate { .. } => "Patch Create",
470 Action::PatchRevise { .. } => "Patch Revise",
471 Action::PatchReview { .. } => "Patch Review",
472 Action::PatchComment { .. } => "Patch Comment",
473 Action::PatchInlineComment { .. } => "Inline Comment",
474 Action::PatchClose { .. } => "Patch Close",
475 Action::PatchMerge => "Patch Merge",
476 Action::Merge => "Merge",
477 Action::IssueEdit { .. } => "Issue Edit",
478 Action::IssueLabel { .. } => "Issue Label",
479 Action::IssueUnlabel { .. } => "Issue Unlabel",
480 Action::IssueAssign { .. } => "Issue Assign",
481 Action::IssueUnassign { .. } => "Issue Unassign",
482 }
483 }
484
485 fn format_event_detail(oid: &Oid, event: &crate::event::Event) -> String {
486 let short_oid = &oid.to_string()[..7];
487 let action_label = action_type_label(&event.action);
488
489 let mut detail = format!(
490 "Commit: {}\nAuthor: {} <{}>\nDate: {}\nType: {}\n",
491 short_oid, event.author.name, event.author.email, event.timestamp, action_label,
492 );
493
494 // Action-specific payload
495 match &event.action {
496 Action::IssueOpen { title, body } => {
497 detail.push_str(&format!("\nTitle: {}\n", title));
498 if !body.is_empty() {
499 detail.push_str(&format!("\n{}\n", body));
500 }
501 }
502 Action::IssueComment { body } | Action::PatchComment { body } => {
503 detail.push_str(&format!("\n{}\n", body));
504 }
505 Action::IssueClose { reason } | Action::PatchClose { reason } => {
506 if let Some(r) = reason {
507 detail.push_str(&format!("\nReason: {}\n", r));
508 }
509 }
510 Action::PatchCreate {
511 title,
512 body,
513 base_ref,
514 head_commit,
515 ..
516 } => {
517 detail.push_str(&format!("\nTitle: {}\n", title));
518 detail.push_str(&format!("Base: {}\n", base_ref));
519 detail.push_str(&format!("Head: {}\n", head_commit));
520 if !body.is_empty() {
521 detail.push_str(&format!("\n{}\n", body));
522 }
523 }
524 Action::PatchRevise { body, head_commit } => {
525 detail.push_str(&format!("\nHead: {}\n", head_commit));
526 if let Some(b) = body {
527 if !b.is_empty() {
528 detail.push_str(&format!("\n{}\n", b));
529 }
530 }
531 }
532 Action::PatchReview { verdict, body } => {
533 detail.push_str(&format!("\nVerdict: {:?}\n", verdict));
534 if !body.is_empty() {
535 detail.push_str(&format!("\n{}\n", body));
536 }
537 }
538 Action::PatchInlineComment { file, line, body } => {
539 detail.push_str(&format!("\nFile: {}:{}\n", file, line));
540 if !body.is_empty() {
541 detail.push_str(&format!("\n{}\n", body));
542 }
543 }
544 Action::IssueEdit { title, body } => {
545 if let Some(t) = title {
546 detail.push_str(&format!("\nNew Title: {}\n", t));
547 }
548 if let Some(b) = body {
549 if !b.is_empty() {
550 detail.push_str(&format!("\nNew Body: {}\n", b));
551 }
552 }
553 }
554 Action::IssueLabel { label } => {
555 detail.push_str(&format!("\nLabel: {}\n", label));
556 }
557 Action::IssueUnlabel { label } => {
558 detail.push_str(&format!("\nRemoved Label: {}\n", label));
559 }
560 Action::IssueAssign { assignee } => {
561 detail.push_str(&format!("\nAssignee: {}\n", assignee));
562 }
563 Action::IssueUnassign { assignee } => {
564 detail.push_str(&format!("\nRemoved Assignee: {}\n", assignee));
565 }
566 Action::IssueReopen | Action::PatchMerge | Action::Merge => {}
567 }
568
569 detail
570 }
571
271 pub fn run(repo: &Repository) -> Result<(), Error> { 572 pub fn run(repo: &Repository) -> Result<(), Error> {
272 let issues = state::list_issues(repo)?; 573 let issues = state::list_issues(repo)?;
273 let patches = state::list_patches(repo)?; 574 let patches = state::list_patches(repo)?;
@@ -423,123 +724,112 @@ fn run_loop(
423 InputMode::Normal => {} 724 InputMode::Normal => {}
424 } 725 }
425 726
426 match key.code { 727 // Handle keys that need repo access or are run_loop-specific
427 KeyCode::Char('q') | KeyCode::Esc => return Ok(()), 728 // before delegating to handle_key
428 KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => { 729 if app.mode == ViewMode::Details || app.mode == ViewMode::Diff {
429 return Ok(()) 730 match key.code {
430 } 731 KeyCode::Char('/') => {
431 KeyCode::Char('/') => { 732 app.input_mode = InputMode::Search;
432 app.input_mode = InputMode::Search; 733 app.search_query.clear();
433 app.search_query.clear(); 734 continue;
434 }
435 KeyCode::Char('n') => {
436 if app.tab != Tab::Issues {
437 app.switch_tab(Tab::Issues);
438 } 735 }
439 app.input_mode = InputMode::CreateTitle; 736 KeyCode::Char('n') => {
440 app.input_buf.clear(); 737 if app.tab != Tab::Issues {
441 app.create_title.clear(); 738 app.switch_tab(Tab::Issues);
442 } 739 }
443 KeyCode::Char('1') => app.switch_tab(Tab::Issues), 740 app.input_mode = InputMode::CreateTitle;
444 KeyCode::Char('2') => app.switch_tab(Tab::Patches), 741 app.input_buf.clear();
445 KeyCode::Char('j') | KeyCode::Down => { 742 app.create_title.clear();
446 if app.pane == Pane::ItemList { 743 continue;
447 app.move_selection(1);
448 } else {
449 app.scroll = app.scroll.saturating_add(1);
450 } 744 }
451 } 745 KeyCode::Char('g') => {
452 KeyCode::Char('k') | KeyCode::Up => { 746 if !app.follow_link() {
453 if app.pane == Pane::ItemList { 747 app.status_msg = Some("No linked item to follow".to_string());
454 app.move_selection(-1); 748 }
455 } else { 749 continue;
456 app.scroll = app.scroll.saturating_sub(1);
457 } 750 }
458 } 751 KeyCode::Char('o') => {
459 KeyCode::PageDown => app.scroll = app.scroll.saturating_add(20), 752 // Check out the relevant commit for local browsing
460 KeyCode::PageUp => app.scroll = app.scroll.saturating_sub(20), 753 let checkout_target = match app.tab {
461 KeyCode::Tab | KeyCode::Enter => { 754 Tab::Patches => {
462 app.pane = match app.pane { 755 let visible = app.visible_patches();
463 Pane::ItemList => Pane::Detail, 756 app.list_state
464 Pane::Detail => Pane::ItemList, 757 .selected()
465 }; 758 .and_then(|idx| visible.get(idx))
466 } 759 .map(|p| p.head_commit.clone())
467 KeyCode::Char('d') => { 760 }
468 if app.tab == Tab::Patches { 761 Tab::Issues => {
469 app.mode = match app.mode { 762 // Find linked patch's head commit, or fall back to closing commit
470 ViewMode::Details => ViewMode::Diff, 763 let visible = app.visible_issues();
471 ViewMode::Diff => ViewMode::Details, 764 app.list_state
765 .selected()
766 .and_then(|idx| visible.get(idx))
767 .and_then(|issue| {
768 // Try linked patch first
769 app.patches
770 .iter()
771 .find(|p| p.fixes.as_deref() == Some(&issue.id))
772 .map(|p| p.head_commit.clone())
773 // Fall back to closing commit
774 .or_else(|| {
775 issue.closed_by.map(|oid| oid.to_string())
776 })
777 })
778 }
472 }; 779 };
473 app.scroll = 0; 780 if let Some(head) = checkout_target {
474 } 781 // Exit TUI, checkout, and return
475 } 782 terminal::disable_raw_mode()?;
476 KeyCode::Char('a') => { 783 stdout().execute(LeaveAlternateScreen)?;
477 app.status_filter = app.status_filter.next(); 784 let status = std::process::Command::new("git")
478 let count = app.visible_count(); 785 .args(["checkout", &head])
479 app.list_state 786 .status();
480 .select(if count > 0 { Some(0) } else { None }); 787 match status {
481 } 788 Ok(s) if s.success() => {
482 KeyCode::Char('g') => { 789 println!("Checked out commit: {:.8}", head);
483 if !app.follow_link() { 790 println!("Use 'git checkout -' to return.");
484 app.status_msg = Some("No linked item to follow".to_string()); 791 }
792 Ok(s) => {
793 eprintln!("git checkout exited with {}", s);
794 }
795 Err(e) => {
796 eprintln!("Failed to run git checkout: {}", e);
797 }
798 }
799 return Ok(());
800 } else {
801 app.status_msg =
802 Some("No linked patch to check out".to_string());
803 }
804 continue;
485 } 805 }
806 _ => {}
486 } 807 }
487 KeyCode::Char('o') => { 808 }
488 // Check out the relevant commit for local browsing 809
489 let checkout_target = match app.tab { 810 match app.handle_key(key.code, key.modifiers) {
490 Tab::Patches => { 811 KeyAction::Quit => return Ok(()),
491 let visible = app.visible_patches(); 812 KeyAction::Reload => app.reload(repo),
492 app.list_state 813 KeyAction::OpenCommitBrowser => {
493 .selected() 814 if let Some(ref_name) = app.selected_ref_name() {
494 .and_then(|idx| visible.get(idx)) 815 match crate::dag::walk_events(repo, &ref_name) {
495 .map(|p| p.head_commit.clone()) 816 Ok(events) => {
496 } 817 app.event_history = events;
497 Tab::Issues => { 818 app.event_list_state = ListState::default();
498 // Find linked patch's head commit, or fall back to closing commit 819 if !app.event_history.is_empty() {
499 let visible = app.visible_issues(); 820 app.event_list_state.select(Some(0));
500 app.list_state 821 }
501 .selected() 822 app.mode = ViewMode::CommitList;
502 .and_then(|idx| visible.get(idx)) 823 app.scroll = 0;
503 .and_then(|issue| {
504 // Try linked patch first
505 app.patches
506 .iter()
507 .find(|p| p.fixes.as_deref() == Some(&issue.id))
508 .map(|p| p.head_commit.clone())
509 // Fall back to closing commit
510 .or_else(|| issue.closed_by.map(|oid| oid.to_string()))
511 })
512 }
513 };
514 if let Some(head) = checkout_target {
515 // Exit TUI, checkout, and return
516 terminal::disable_raw_mode()?;
517 stdout().execute(LeaveAlternateScreen)?;
518 let status = std::process::Command::new("git")
519 .args(["checkout", &head])
520 .status();
521 match status {
522 Ok(s) if s.success() => {
523 println!("Checked out commit: {:.8}", head);
524 println!("Use 'git checkout -' to return.");
525 }
526 Ok(s) => {
527 eprintln!("git checkout exited with {}", s);
528 } 824 }
529 Err(e) => { 825 Err(e) => {
530 eprintln!("Failed to run git checkout: {}", e); 826 app.status_msg =
827 Some(format!("Error loading events: {}", e));
531 } 828 }
532 } 829 }
533 return Ok(());
534 } else {
535 app.status_msg =
536 Some("No linked patch to check out".to_string());
537 } 830 }
538 } 831 }
539 KeyCode::Char('r') => { 832 KeyAction::Continue => {}
540 app.reload(repo);
541 }
542 _ => {}
543 } 833 }
544 } 834 }
545 } 835 }
@@ -669,17 +959,75 @@ fn render_list(frame: &mut Frame, app: &mut App, area: Rect) {
669 } 959 }
670 } 960 }
671 961
672 fn render_detail(frame: &mut Frame, app: &App, area: Rect) { 962 fn render_detail(frame: &mut Frame, app: &mut App, area: Rect) {
673 let border_style = if app.pane == Pane::Detail { 963 let border_style = if app.pane == Pane::Detail {
674 Style::default().fg(Color::Yellow) 964 Style::default().fg(Color::Yellow)
675 } else { 965 } else {
676 Style::default().fg(Color::DarkGray) 966 Style::default().fg(Color::DarkGray)
677 }; 967 };
678 968
969 // Handle commit browser modes
970 if app.mode == ViewMode::CommitList {
971 let items: Vec<ListItem> = app
972 .event_history
973 .iter()
974 .map(|(_oid, evt)| {
975 let label = action_type_label(&evt.action);
976 ListItem::new(format!(
977 "{} | {} | {}",
978 label, evt.author.name, evt.timestamp
979 ))
980 })
981 .collect();
982
983 let list = List::new(items)
984 .block(
985 Block::default()
986 .borders(Borders::ALL)
987 .title("Event History")
988 .border_style(border_style),
989 )
990 .highlight_style(
991 Style::default()
992 .bg(Color::DarkGray)
993 .add_modifier(Modifier::BOLD),
994 )
995 .highlight_symbol("> ");
996
997 frame.render_stateful_widget(list, area, &mut app.event_list_state);
998 return;
999 }
1000
1001 if app.mode == ViewMode::CommitDetail {
1002 let content = if let Some(idx) = app.event_list_state.selected() {
1003 if let Some((oid, evt)) = app.event_history.get(idx) {
1004 format_event_detail(oid, evt)
1005 } else {
1006 "No event selected.".to_string()
1007 }
1008 } else {
1009 "No event selected.".to_string()
1010 };
1011
1012 let block = Block::default()
1013 .borders(Borders::ALL)
1014 .title("Event Detail")
1015 .border_style(border_style);
1016
1017 let para = Paragraph::new(content)
1018 .block(block)
1019 .wrap(Wrap { trim: false })
1020 .scroll((app.scroll, 0));
1021
1022 frame.render_widget(para, area);
1023 return;
1024 }
1025
679 let title = match (&app.tab, &app.mode) { 1026 let title = match (&app.tab, &app.mode) {
680 (Tab::Issues, _) => "Issue Details", 1027 (Tab::Issues, _) => "Issue Details",
681 (Tab::Patches, ViewMode::Details) => "Patch Details", 1028 (Tab::Patches, ViewMode::Details) => "Patch Details",
682 (Tab::Patches, ViewMode::Diff) => "Diff", 1029 (Tab::Patches, ViewMode::Diff) => "Diff",
1030 _ => "Details",
683 }; 1031 };
684 1032
685 let content: Text = match app.tab { 1033 let content: Text = match app.tab {
@@ -705,6 +1053,7 @@ fn render_detail(frame: &mut Frame, app: &App, area: Rect) {
705 .unwrap_or("Loading..."); 1053 .unwrap_or("Loading...");
706 colorize_diff(diff_text, &patch.inline_comments) 1054 colorize_diff(diff_text, &patch.inline_comments)
707 } 1055 }
1056 _ => Text::raw(""),
708 }, 1057 },
709 None => Text::raw("No matches for current filter."), 1058 None => Text::raw("No matches for current filter."),
710 } 1059 }
@@ -1166,33 +1515,39 @@ fn render_footer(frame: &mut Frame, app: &App, area: Rect) {
1166 InputMode::Normal => {} 1515 InputMode::Normal => {}
1167 } 1516 }
1168 1517
1169 let mode_hint = if app.tab == Tab::Patches { 1518 // Show status message if present
1170 match app.mode { 1519 if let Some(ref msg) = app.status_msg {
1171 ViewMode::Details => " d:diff", 1520 let para =
1172 ViewMode::Diff => " d:details", 1521 Paragraph::new(format!(" {}", msg)).style(Style::default().bg(Color::Yellow).fg(Color::Black));
1522 frame.render_widget(para, area);
1523 return;
1524 }
1525
1526 let mode_hint = match app.mode {
1527 ViewMode::CommitList => " Esc:back",
1528 ViewMode::CommitDetail => " Esc:back j/k:scroll",
1529 _ => {
1530 if app.tab == Tab::Patches {
1531 match app.mode {
1532 ViewMode::Details => " d:diff c:events",
1533 ViewMode::Diff => " d:details c:events",
1534 _ => "",
1535 }
1536 } else {
1537 " c:events"
1538 }
1173 } 1539 }
1174 } else {
1175 ""
1176 }; 1540 };
1177 let filter_hint = match app.status_filter { 1541 let filter_hint = match app.status_filter {
1178 StatusFilter::Open => "a:show all", 1542 StatusFilter::Open => "a:show all",
1179 StatusFilter::All => "a:closed", 1543 StatusFilter::All => "a:closed",
1180 StatusFilter::Closed => "a:open only", 1544 StatusFilter::Closed => "a:open only",
1181 }; 1545 };
1182 let text = if let Some(ref msg) = app.status_msg { 1546 let text = format!(
1183 format!(" {}", msg) 1547 " 1:issues 2:patches j/k:navigate Tab:pane {}{} /:search n:new issue g:follow o:checkout r:refresh q:quit",
1184 } else { 1548 filter_hint, mode_hint
1185 format!( 1549 );
1186 " 1:issues 2:patches j/k:navigate Tab:pane {}{} /:search n:new issue g:follow o:checkout r:refresh q:quit", 1550 let para = Paragraph::new(text).style(Style::default().bg(Color::DarkGray).fg(Color::White));
1187 filter_hint, mode_hint
1188 )
1189 };
1190 let style = if app.status_msg.is_some() {
1191 Style::default().bg(Color::Yellow).fg(Color::Black)
1192 } else {
1193 Style::default().bg(Color::DarkGray).fg(Color::White)
1194 };
1195 let para = Paragraph::new(text).style(style);
1196 frame.render_widget(para, area); 1551 frame.render_widget(para, area);
1197 } 1552 }
1198 1553
@@ -1396,4 +1751,734 @@ mod tests {
1396 1751
1397 assert_eq!(app.input_mode, InputMode::Normal); 1752 assert_eq!(app.input_mode, InputMode::Normal);
1398 } 1753 }
1754
1755 // ── Commit browser test helpers ─────────────────────────────────────
1756
1757 use crate::event::ReviewVerdict;
1758 use ratatui::backend::TestBackend;
1759 use ratatui::buffer::Buffer;
1760
1761 fn test_author() -> Author {
1762 Author {
1763 name: "Test User".to_string(),
1764 email: "test@example.com".to_string(),
1765 }
1766 }
1767
1768 fn make_test_issues(n: usize) -> Vec<IssueState> {
1769 (0..n)
1770 .map(|i| IssueState {
1771 id: format!("{:08x}", i),
1772 title: format!("Issue {}", i),
1773 body: format!("Body for issue {}", i),
1774 status: if i % 2 == 0 {
1775 IssueStatus::Open
1776 } else {
1777 IssueStatus::Closed
1778 },
1779 close_reason: if i % 2 == 1 {
1780 Some("done".to_string())
1781 } else {
1782 None
1783 },
1784 closed_by: None,
1785 labels: vec![],
1786 assignees: vec![],
1787 comments: Vec::new(),
1788 created_at: "2026-01-01T00:00:00Z".to_string(),
1789 author: test_author(),
1790 })
1791 .collect()
1792 }
1793
1794 fn make_test_patches(n: usize) -> Vec<PatchState> {
1795 (0..n)
1796 .map(|i| PatchState {
1797 id: format!("p{:07x}", i),
1798 title: format!("Patch {}", i),
1799 body: format!("Body for patch {}", i),
1800 status: if i % 2 == 0 {
1801 PatchStatus::Open
1802 } else {
1803 PatchStatus::Closed
1804 },
1805 base_ref: "main".to_string(),
1806 head_commit: format!("h{:07x}", i),
1807 fixes: None,
1808 comments: Vec::new(),
1809 inline_comments: Vec::new(),
1810 reviews: Vec::new(),
1811 created_at: "2026-01-01T00:00:00Z".to_string(),
1812 author: test_author(),
1813 })
1814 .collect()
1815 }
1816
1817 fn make_app(issues: usize, patches: usize) -> App {
1818 App::new(make_test_issues(issues), make_test_patches(patches))
1819 }
1820
1821 fn render_app(app: &mut App) -> Buffer {
1822 let backend = TestBackend::new(80, 24);
1823 let mut terminal = Terminal::new(backend).unwrap();
1824 terminal.draw(|frame| ui(frame, app)).unwrap();
1825 terminal.backend().buffer().clone()
1826 }
1827
1828 fn buffer_to_string(buf: &Buffer) -> String {
1829 let area = buf.area;
1830 let mut s = String::new();
1831 for y in area.y..area.y + area.height {
1832 for x in area.x..area.x + area.width {
1833 let cell = buf.cell((x, y)).unwrap();
1834 s.push_str(cell.symbol());
1835 }
1836 s.push('\n');
1837 }
1838 s
1839 }
1840
1841 fn assert_buffer_contains(buf: &Buffer, expected: &str) {
1842 let text = buffer_to_string(buf);
1843 assert!(
1844 text.contains(expected),
1845 "expected buffer to contain {:?}, but it was not found in:\n{}",
1846 expected,
1847 text
1848 );
1849 }
1850
1851 /// Create sample event history for testing commit browser
1852 fn make_test_event_history() -> Vec<(Oid, crate::event::Event)> {
1853 let oid1 = Oid::from_str("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa").unwrap();
1854 let oid2 = Oid::from_str("bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb").unwrap();
1855 let oid3 = Oid::from_str("cccccccccccccccccccccccccccccccccccccccc").unwrap();
1856
1857 vec![
1858 (
1859 oid1,
1860 crate::event::Event {
1861 timestamp: "2026-01-01T00:00:00Z".to_string(),
1862 author: test_author(),
1863 action: Action::IssueOpen {
1864 title: "Test Issue".to_string(),
1865 body: "This is the body".to_string(),
1866 },
1867 },
1868 ),
1869 (
1870 oid2,
1871 crate::event::Event {
1872 timestamp: "2026-01-02T00:00:00Z".to_string(),
1873 author: Author {
1874 name: "Other User".to_string(),
1875 email: "other@example.com".to_string(),
1876 },
1877 action: Action::IssueComment {
1878 body: "A comment on the issue".to_string(),
1879 },
1880 },
1881 ),
1882 (
1883 oid3,
1884 crate::event::Event {
1885 timestamp: "2026-01-03T00:00:00Z".to_string(),
1886 author: test_author(),
1887 action: Action::IssueClose {
1888 reason: Some("fixed".to_string()),
1889 },
1890 },
1891 ),
1892 ]
1893 }
1894
1895 // ── action_type_label tests ──────────────────────────────────────────
1896
1897 #[test]
1898 fn test_action_type_label_issue_open() {
1899 let action = Action::IssueOpen {
1900 title: "t".to_string(),
1901 body: "b".to_string(),
1902 };
1903 assert_eq!(action_type_label(&action), "Issue Open");
1904 }
1905
1906 #[test]
1907 fn test_action_type_label_issue_comment() {
1908 let action = Action::IssueComment {
1909 body: "b".to_string(),
1910 };
1911 assert_eq!(action_type_label(&action), "Issue Comment");
1912 }
1913
1914 #[test]
1915 fn test_action_type_label_issue_close() {
1916 let action = Action::IssueClose { reason: None };
1917 assert_eq!(action_type_label(&action), "Issue Close");
1918 }
1919
1920 #[test]
1921 fn test_action_type_label_issue_reopen() {
1922 assert_eq!(action_type_label(&Action::IssueReopen), "Issue Reopen");
1923 }
1924
1925 #[test]
1926 fn test_action_type_label_patch_create() {
1927 let action = Action::PatchCreate {
1928 title: "t".to_string(),
1929 body: "b".to_string(),
1930 base_ref: "main".to_string(),
1931 head_commit: "abc".to_string(),
1932 fixes: None,
1933 };
1934 assert_eq!(action_type_label(&action), "Patch Create");
1935 }
1936
1937 #[test]
1938 fn test_action_type_label_patch_review() {
1939 let action = Action::PatchReview {
1940 verdict: ReviewVerdict::Approve,
1941 body: "lgtm".to_string(),
1942 };
1943 assert_eq!(action_type_label(&action), "Patch Review");
1944 }
1945
1946 #[test]
1947 fn test_action_type_label_inline_comment() {
1948 let action = Action::PatchInlineComment {
1949 file: "src/main.rs".to_string(),
1950 line: 42,
1951 body: "nit".to_string(),
1952 };
1953 assert_eq!(action_type_label(&action), "Inline Comment");
1954 }
1955
1956 #[test]
1957 fn test_action_type_label_merge() {
1958 assert_eq!(action_type_label(&Action::Merge), "Merge");
1959 }
1960
1961 // ── format_event_detail tests ────────────────────────────────────────
1962
1963 #[test]
1964 fn test_format_event_detail_issue_open() {
1965 let oid = Oid::from_str("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa").unwrap();
1966 let event = crate::event::Event {
1967 timestamp: "2026-01-01T00:00:00Z".to_string(),
1968 author: test_author(),
1969 action: Action::IssueOpen {
1970 title: "My Issue".to_string(),
1971 body: "Description here".to_string(),
1972 },
1973 };
1974 let detail = format_event_detail(&oid, &event);
1975 assert!(detail.contains("aaaaaaa"));
1976 assert!(detail.contains("Test User <test@example.com>"));
1977 assert!(detail.contains("2026-01-01T00:00:00Z"));
1978 assert!(detail.contains("Issue Open"));
1979 assert!(detail.contains("Title: My Issue"));
1980 assert!(detail.contains("Description here"));
1981 }
1982
1983 #[test]
1984 fn test_format_event_detail_issue_close_with_reason() {
1985 let oid = Oid::from_str("bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb").unwrap();
1986 let event = crate::event::Event {
1987 timestamp: "2026-02-01T00:00:00Z".to_string(),
1988 author: test_author(),
1989 action: Action::IssueClose {
1990 reason: Some("resolved".to_string()),
1991 },
1992 };
1993 let detail = format_event_detail(&oid, &event);
1994 assert!(detail.contains("Issue Close"));
1995 assert!(detail.contains("Reason: resolved"));
1996 }
1997
1998 #[test]
1999 fn test_format_event_detail_patch_review() {
2000 let oid = Oid::from_str("cccccccccccccccccccccccccccccccccccccccc").unwrap();
2001 let event = crate::event::Event {
2002 timestamp: "2026-03-01T00:00:00Z".to_string(),
2003 author: test_author(),
2004 action: Action::PatchReview {
2005 verdict: ReviewVerdict::Approve,
2006 body: "Looks good!".to_string(),
2007 },
2008 };
2009 let detail = format_event_detail(&oid, &event);
2010 assert!(detail.contains("Patch Review"));
2011 assert!(detail.contains("Approve"));
2012 assert!(detail.contains("Looks good!"));
2013 }
2014
2015 #[test]
2016 fn test_format_event_detail_short_oid() {
2017 let oid = Oid::from_str("1234567890abcdef1234567890abcdef12345678").unwrap();
2018 let event = crate::event::Event {
2019 timestamp: "2026-01-01T00:00:00Z".to_string(),
2020 author: test_author(),
2021 action: Action::IssueReopen,
2022 };
2023 let detail = format_event_detail(&oid, &event);
2024 assert!(detail.contains("1234567"));
2025 assert!(detail.contains("Commit: 1234567\n"));
2026 }
2027
2028 // ── handle_key tests for 'c' key ─────────────────────────────────────
2029
2030 #[test]
2031 fn test_handle_key_c_in_detail_pane_returns_open_commit_browser() {
2032 let mut app = make_app(3, 0);
2033 app.pane = Pane::Detail;
2034 app.list_state.select(Some(0));
2035 let result = app.handle_key(KeyCode::Char('c'), KeyModifiers::empty());
2036 assert_eq!(result, KeyAction::OpenCommitBrowser);
2037 }
2038
2039 #[test]
2040 fn test_handle_key_c_in_item_list_pane_is_noop() {
2041 let mut app = make_app(3, 0);
2042 app.pane = Pane::ItemList;
2043 app.list_state.select(Some(0));
2044 let result = app.handle_key(KeyCode::Char('c'), KeyModifiers::empty());
2045 assert_eq!(result, KeyAction::Continue);
2046 }
2047
2048 #[test]
2049 fn test_handle_key_c_no_selection_is_noop() {
2050 let mut app = make_app(0, 0);
2051 app.pane = Pane::Detail;
2052 let result = app.handle_key(KeyCode::Char('c'), KeyModifiers::empty());
2053 assert_eq!(result, KeyAction::Continue);
2054 }
2055
2056 #[test]
2057 fn test_handle_key_ctrl_c_still_quits() {
2058 let mut app = make_app(3, 0);
2059 let result = app.handle_key(KeyCode::Char('c'), KeyModifiers::CONTROL);
2060 assert_eq!(result, KeyAction::Quit);
2061 }
2062
2063 // ── CommitList navigation tests ──────────────────────────────────────
2064
2065 #[test]
2066 fn test_commit_list_navigate_down() {
2067 let mut app = make_app(3, 0);
2068 app.event_history = make_test_event_history();
2069 app.event_list_state.select(Some(0));
2070 app.mode = ViewMode::CommitList;
2071
2072 app.handle_key(KeyCode::Char('j'), KeyModifiers::empty());
2073 assert_eq!(app.event_list_state.selected(), Some(1));
2074 }
2075
2076 #[test]
2077 fn test_commit_list_navigate_up() {
2078 let mut app = make_app(3, 0);
2079 app.event_history = make_test_event_history();
2080 app.event_list_state.select(Some(2));
2081 app.mode = ViewMode::CommitList;
2082
2083 app.handle_key(KeyCode::Char('k'), KeyModifiers::empty());
2084 assert_eq!(app.event_list_state.selected(), Some(1));
2085 }
2086
2087 #[test]
2088 fn test_commit_list_navigate_clamp_bottom() {
2089 let mut app = make_app(3, 0);
2090 app.event_history = make_test_event_history();
2091 app.event_list_state.select(Some(2));
2092 app.mode = ViewMode::CommitList;
2093
2094 app.handle_key(KeyCode::Down, KeyModifiers::empty());
2095 assert_eq!(app.event_list_state.selected(), Some(2));
2096 }
2097
2098 #[test]
2099 fn test_commit_list_navigate_clamp_top() {
2100 let mut app = make_app(3, 0);
2101 app.event_history = make_test_event_history();
2102 app.event_list_state.select(Some(0));
2103 app.mode = ViewMode::CommitList;
2104
2105 app.handle_key(KeyCode::Up, KeyModifiers::empty());
2106 assert_eq!(app.event_list_state.selected(), Some(0));
2107 }
2108
2109 #[test]
2110 fn test_commit_list_escape_returns_to_details() {
2111 let mut app = make_app(3, 0);
2112 app.event_history = make_test_event_history();
2113 app.event_list_state.select(Some(1));
2114 app.mode = ViewMode::CommitList;
2115
2116 let result = app.handle_key(KeyCode::Esc, KeyModifiers::empty());
2117 assert_eq!(result, KeyAction::Continue);
2118 assert_eq!(app.mode, ViewMode::Details);
2119 assert!(app.event_history.is_empty());
2120 assert_eq!(app.event_list_state.selected(), None);
2121 }
2122
2123 #[test]
2124 fn test_commit_list_q_quits() {
2125 let mut app = make_app(3, 0);
2126 app.mode = ViewMode::CommitList;
2127 let result = app.handle_key(KeyCode::Char('q'), KeyModifiers::empty());
2128 assert_eq!(result, KeyAction::Quit);
2129 }
2130
2131 // ── CommitDetail tests ───────────────────────────────────────────────
2132
2133 #[test]
2134 fn test_commit_list_enter_opens_detail() {
2135 let mut app = make_app(3, 0);
2136 app.event_history = make_test_event_history();
2137 app.event_list_state.select(Some(1));
2138 app.mode = ViewMode::CommitList;
2139
2140 let result = app.handle_key(KeyCode::Enter, KeyModifiers::empty());
2141 assert_eq!(result, KeyAction::Continue);
2142 assert_eq!(app.mode, ViewMode::CommitDetail);
2143 assert_eq!(app.scroll, 0);
2144 }
2145
2146 #[test]
2147 fn test_commit_list_enter_no_selection_stays() {
2148 let mut app = make_app(3, 0);
2149 app.event_history = make_test_event_history();
2150 app.event_list_state = ListState::default();
2151 app.mode = ViewMode::CommitList;
2152
2153 app.handle_key(KeyCode::Enter, KeyModifiers::empty());
2154 assert_eq!(app.mode, ViewMode::CommitList);
2155 }
2156
2157 #[test]
2158 fn test_commit_detail_escape_returns_to_list() {
2159 let mut app = make_app(3, 0);
2160 app.event_history = make_test_event_history();
2161 app.event_list_state.select(Some(0));
2162 app.mode = ViewMode::CommitDetail;
2163 app.scroll = 5;
2164
2165 let result = app.handle_key(KeyCode::Esc, KeyModifiers::empty());
2166 assert_eq!(result, KeyAction::Continue);
2167 assert_eq!(app.mode, ViewMode::CommitList);
2168 assert_eq!(app.scroll, 0);
2169 assert_eq!(app.event_history.len(), 3);
2170 }
2171
2172 #[test]
2173 fn test_commit_detail_scroll() {
2174 let mut app = make_app(3, 0);
2175 app.mode = ViewMode::CommitDetail;
2176 app.scroll = 0;
2177
2178 app.handle_key(KeyCode::Char('j'), KeyModifiers::empty());
2179 assert_eq!(app.scroll, 1);
2180 app.handle_key(KeyCode::Char('j'), KeyModifiers::empty());
2181 assert_eq!(app.scroll, 2);
2182 app.handle_key(KeyCode::Char('k'), KeyModifiers::empty());
2183 assert_eq!(app.scroll, 1);
2184 }
2185
2186 #[test]
2187 fn test_commit_detail_page_scroll() {
2188 let mut app = make_app(3, 0);
2189 app.mode = ViewMode::CommitDetail;
2190 app.scroll = 0;
2191
2192 app.handle_key(KeyCode::PageDown, KeyModifiers::empty());
2193 assert_eq!(app.scroll, 20);
2194 app.handle_key(KeyCode::PageUp, KeyModifiers::empty());
2195 assert_eq!(app.scroll, 0);
2196 }
2197
2198 #[test]
2199 fn test_commit_detail_q_quits() {
2200 let mut app = make_app(3, 0);
2201 app.mode = ViewMode::CommitDetail;
2202 let result = app.handle_key(KeyCode::Char('q'), KeyModifiers::empty());
2203 assert_eq!(result, KeyAction::Quit);
2204 }
2205
2206 // ── Guard tests ──────────────────────────────────────────────────────
2207
2208 #[test]
2209 fn test_c_ignored_in_commit_list_mode() {
2210 let mut app = make_app(3, 0);
2211 app.mode = ViewMode::CommitList;
2212 let result = app.handle_key(KeyCode::Char('c'), KeyModifiers::empty());
2213 assert_eq!(result, KeyAction::Continue);
2214 assert_eq!(app.mode, ViewMode::CommitList);
2215 }
2216
2217 #[test]
2218 fn test_c_ignored_in_commit_detail_mode() {
2219 let mut app = make_app(3, 0);
2220 app.mode = ViewMode::CommitDetail;
2221 let result = app.handle_key(KeyCode::Char('c'), KeyModifiers::empty());
2222 assert_eq!(result, KeyAction::Continue);
2223 assert_eq!(app.mode, ViewMode::CommitDetail);
2224 }
2225
2226 // ── handle_key basic tests ───────────────────────────────────────────
2227
2228 #[test]
2229 fn test_handle_key_quit() {
2230 let mut app = make_app(3, 3);
2231 assert_eq!(
2232 app.handle_key(KeyCode::Char('q'), KeyModifiers::empty()),
2233 KeyAction::Quit
2234 );
2235 }
2236
2237 #[test]
2238 fn test_handle_key_quit_esc() {
2239 let mut app = make_app(3, 3);
2240 assert_eq!(
2241 app.handle_key(KeyCode::Esc, KeyModifiers::empty()),
2242 KeyAction::Quit
2243 );
2244 }
2245
2246 #[test]
2247 fn test_handle_key_quit_ctrl_c() {
2248 let mut app = make_app(3, 3);
2249 assert_eq!(
2250 app.handle_key(KeyCode::Char('c'), KeyModifiers::CONTROL),
2251 KeyAction::Quit
2252 );
2253 }
2254
2255 #[test]
2256 fn test_handle_key_tab_switch() {
2257 let mut app = make_app(3, 3);
2258 app.handle_key(KeyCode::Char('2'), KeyModifiers::empty());
2259 assert_eq!(app.tab, Tab::Patches);
2260 app.handle_key(KeyCode::Char('1'), KeyModifiers::empty());
2261 assert_eq!(app.tab, Tab::Issues);
2262 }
2263
2264 #[test]
2265 fn test_handle_key_diff_toggle_patches() {
2266 let mut app = make_app(0, 3);
2267 app.switch_tab(Tab::Patches);
2268 assert_eq!(app.mode, ViewMode::Details);
2269 app.handle_key(KeyCode::Char('d'), KeyModifiers::empty());
2270 assert_eq!(app.mode, ViewMode::Diff);
2271 app.handle_key(KeyCode::Char('d'), KeyModifiers::empty());
2272 assert_eq!(app.mode, ViewMode::Details);
2273 }
2274
2275 #[test]
2276 fn test_handle_key_diff_noop_issues() {
2277 let mut app = make_app(3, 0);
2278 assert_eq!(app.tab, Tab::Issues);
2279 assert_eq!(app.mode, ViewMode::Details);
2280 app.handle_key(KeyCode::Char('d'), KeyModifiers::empty());
2281 assert_eq!(app.mode, ViewMode::Details);
2282 }
2283
2284 #[test]
2285 fn test_handle_key_pane_toggle() {
2286 let mut app = make_app(3, 3);
2287 assert_eq!(app.pane, Pane::ItemList);
2288 app.handle_key(KeyCode::Tab, KeyModifiers::empty());
2289 assert_eq!(app.pane, Pane::Detail);
2290 app.handle_key(KeyCode::Enter, KeyModifiers::empty());
2291 assert_eq!(app.pane, Pane::ItemList);
2292 }
2293
2294 #[test]
2295 fn test_scroll_in_detail_pane() {
2296 let mut app = make_app(3, 0);
2297 app.list_state.select(Some(0));
2298 app.pane = Pane::Detail;
2299 app.scroll = 0;
2300
2301 app.handle_key(KeyCode::Char('j'), KeyModifiers::empty());
2302 assert_eq!(app.scroll, 1);
2303 assert_eq!(app.list_state.selected(), Some(0));
2304
2305 app.handle_key(KeyCode::Char('k'), KeyModifiers::empty());
2306 assert_eq!(app.scroll, 0);
2307 }
2308
2309 #[test]
2310 fn test_handle_key_reload() {
2311 let mut app = make_app(3, 3);
2312 assert_eq!(
2313 app.handle_key(KeyCode::Char('r'), KeyModifiers::empty()),
2314 KeyAction::Reload
2315 );
2316 }
2317
2318 // ── selected_ref_name tests ──────────────────────────────────────────
2319
2320 #[test]
2321 fn test_selected_ref_name_issues() {
2322 let app = make_app(3, 0);
2323 let ref_name = app.selected_ref_name();
2324 assert_eq!(
2325 ref_name,
2326 Some("refs/collab/issues/00000000".to_string())
2327 );
2328 }
2329
2330 #[test]
2331 fn test_selected_ref_name_patches() {
2332 let mut app = make_app(0, 3);
2333 app.switch_tab(Tab::Patches);
2334 let ref_name = app.selected_ref_name();
2335 assert_eq!(
2336 ref_name,
2337 Some("refs/collab/patches/p0000000".to_string())
2338 );
2339 }
2340
2341 #[test]
2342 fn test_selected_ref_name_none_when_empty() {
2343 let app = make_app(0, 0);
2344 assert_eq!(app.selected_ref_name(), None);
2345 }
2346
2347 // ── Render tests ─────────────────────────────────────────────────────
2348
2349 #[test]
2350 fn test_render_issues_tab() {
2351 let mut app = make_app(3, 2);
2352 app.status_filter = StatusFilter::All;
2353 let buf = render_app(&mut app);
2354 assert_buffer_contains(&buf, "1:Issues");
2355 assert_buffer_contains(&buf, "2:Patches");
2356 assert_buffer_contains(&buf, "00000000");
2357 assert_buffer_contains(&buf, "00000001");
2358 assert_buffer_contains(&buf, "00000002");
2359 assert_buffer_contains(&buf, "Issue 0");
2360 }
2361
2362 #[test]
2363 fn test_render_patches_tab() {
2364 let mut app = make_app(2, 3);
2365 app.status_filter = StatusFilter::All;
2366 app.switch_tab(Tab::Patches);
2367 let buf = render_app(&mut app);
2368 assert_buffer_contains(&buf, "p0000000");
2369 assert_buffer_contains(&buf, "p0000001");
2370 assert_buffer_contains(&buf, "p0000002");
2371 assert_buffer_contains(&buf, "Patch 0");
2372 }
2373
2374 #[test]
2375 fn test_render_empty_state() {
2376 let mut app = make_app(0, 0);
2377 let buf = render_app(&mut app);
2378 assert_buffer_contains(&buf, "No matches for current filter.");
2379 }
2380
2381 #[test]
2382 fn test_render_footer_keys() {
2383 let mut app = make_app(3, 3);
2384 let buf = render_app(&mut app);
2385 assert_buffer_contains(&buf, "j/k:navigate");
2386 assert_buffer_contains(&buf, "Tab:pane");
2387 assert_buffer_contains(&buf, "c:events");
2388 }
2389
2390 #[test]
2391 fn test_render_commit_list() {
2392 let mut app = make_app(3, 0);
2393 app.event_history = make_test_event_history();
2394 app.event_list_state.select(Some(0));
2395 app.mode = ViewMode::CommitList;
2396 let buf = render_app(&mut app);
2397 assert_buffer_contains(&buf, "Event History");
2398 assert_buffer_contains(&buf, "Issue Open");
2399 assert_buffer_contains(&buf, "Issue Comment");
2400 assert_buffer_contains(&buf, "Issue Close");
2401 }
2402
2403 #[test]
2404 fn test_render_commit_detail() {
2405 let mut app = make_app(3, 0);
2406 app.event_history = make_test_event_history();
2407 app.event_list_state.select(Some(0));
2408 app.mode = ViewMode::CommitDetail;
2409 let buf = render_app(&mut app);
2410 assert_buffer_contains(&buf, "Event Detail");
2411 assert_buffer_contains(&buf, "aaaaaaa");
2412 assert_buffer_contains(&buf, "Test User");
2413 assert_buffer_contains(&buf, "Issue Open");
2414 }
2415
2416 #[test]
2417 fn test_render_commit_list_footer() {
2418 let mut app = make_app(3, 0);
2419 app.mode = ViewMode::CommitList;
2420 let buf = render_app(&mut app);
2421 assert_buffer_contains(&buf, "Esc:back");
2422 }
2423
2424 #[test]
2425 fn test_render_commit_detail_footer() {
2426 let mut app = make_app(3, 0);
2427 app.mode = ViewMode::CommitDetail;
2428 let buf = render_app(&mut app);
2429 assert_buffer_contains(&buf, "Esc:back");
2430 assert_buffer_contains(&buf, "j/k:scroll");
2431 }
2432
2433 #[test]
2434 fn test_render_small_terminal() {
2435 let mut app = make_app(3, 3);
2436 let backend = TestBackend::new(20, 10);
2437 let mut terminal = Terminal::new(backend).unwrap();
2438 terminal.draw(|frame| ui(frame, &mut app)).unwrap();
2439 }
2440
2441 // ── Integration: full browse flow ────────────────────────────────────
2442
2443 #[test]
2444 fn test_full_commit_browse_flow() {
2445 let mut app = make_app(3, 0);
2446 app.pane = Pane::Detail;
2447 app.list_state.select(Some(0));
2448
2449 let action = app.handle_key(KeyCode::Char('c'), KeyModifiers::empty());
2450 assert_eq!(action, KeyAction::OpenCommitBrowser);
2451
2452 app.event_history = make_test_event_history();
2453 app.event_list_state.select(Some(0));
2454 app.mode = ViewMode::CommitList;
2455 app.scroll = 0;
2456
2457 app.handle_key(KeyCode::Char('j'), KeyModifiers::empty());
2458 assert_eq!(app.event_list_state.selected(), Some(1));
2459
2460 app.handle_key(KeyCode::Enter, KeyModifiers::empty());
2461 assert_eq!(app.mode, ViewMode::CommitDetail);
2462
2463 app.handle_key(KeyCode::Char('j'), KeyModifiers::empty());
2464 assert_eq!(app.scroll, 1);
2465
2466 app.handle_key(KeyCode::Esc, KeyModifiers::empty());
2467 assert_eq!(app.mode, ViewMode::CommitList);
2468 assert_eq!(app.scroll, 0);
2469
2470 app.handle_key(KeyCode::Esc, KeyModifiers::empty());
2471 assert_eq!(app.mode, ViewMode::Details);
2472 assert!(app.event_history.is_empty());
2473 }
2474
2475 // ── Status message render test ───────────────────────────────────────
2476
2477 #[test]
2478 fn test_render_status_message() {
2479 let mut app = make_app(3, 0);
2480 app.status_msg = Some("Error loading events: ref not found".to_string());
2481 let buf = render_app(&mut app);
2482 assert_buffer_contains(&buf, "Error loading events");
2483 }
1399 } 2484 }
tests/patch_import_test.rs
Old New
@@ -0,0 +1,298 @@
1 use git2::Repository;
2 use std::path::{Path, PathBuf};
3 use tempfile::TempDir;
4
5 use git_collab::event::Author;
6 use git_collab::patch;
7 use git_collab::state::{self, PatchStatus};
8
9 // ---------------------------------------------------------------------------
10 // Helpers
11 // ---------------------------------------------------------------------------
12
13 fn alice() -> Author {
14 Author {
15 name: "Alice".to_string(),
16 email: "alice@example.com".to_string(),
17 }
18 }
19
20 /// Create a repo with an initial commit so we have a valid HEAD and tree.
21 fn init_repo_with_commit(dir: &Path, author: &Author) -> Repository {
22 let repo = Repository::init(dir).expect("init repo");
23 {
24 let mut config = repo.config().unwrap();
25 config.set_str("user.name", &author.name).unwrap();
26 config.set_str("user.email", &author.email).unwrap();
27 }
28
29 // Create an initial commit with a file so we have a valid tree/HEAD
30 let sig = git2::Signature::now(&author.name, &author.email).unwrap();
31 let tree_oid = {
32 let blob_oid = repo.blob(b"initial content\n").unwrap();
33 let mut tb = repo.treebuilder(None).unwrap();
34 tb.insert("README", blob_oid, 0o100644).unwrap();
35 tb.write().unwrap()
36 };
37 {
38 let tree = repo.find_tree(tree_oid).unwrap();
39 repo.commit(Some("refs/heads/main"), &sig, &sig, "Initial commit", &tree, &[])
40 .unwrap();
41 }
42
43 // Set HEAD to point to main
44 repo.set_head("refs/heads/main").unwrap();
45
46 repo
47 }
48
49 /// Generate a valid git format-patch style .patch file content.
50 /// This creates a patch that adds a new file called `filename` with `content`.
51 fn make_format_patch(
52 from_name: &str,
53 from_email: &str,
54 subject: &str,
55 body: &str,
56 filename: &str,
57 content: &str,
58 ) -> String {
59 let date = "Thu, 19 Mar 2026 10:30:00 +0000";
60 // Build the diff portion
61 let lines: Vec<&str> = content.lines().collect();
62 let mut diff_lines = String::new();
63 for line in &lines {
64 diff_lines.push_str(&format!("+{}\n", line));
65 }
66 let line_count = lines.len();
67
68 format!(
69 "From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001\n\
70 From: {} <{}>\n\
71 Date: {}\n\
72 Subject: [PATCH] {}\n\
73 \n\
74 {}\n\
75 ---\n\
76 {filename} | {line_count} +\n\
77 1 file changed, {line_count} insertions(+)\n\
78 create mode 100644 {filename}\n\
79 \n\
80 diff --git a/{filename} b/{filename}\n\
81 new file mode 100644\n\
82 index 0000000..1234567\n\
83 --- /dev/null\n\
84 +++ b/{filename}\n\
85 @@ -0,0 +1,{line_count} @@\n\
86 {diff_lines}\
87 -- \n\
88 2.40.0\n",
89 from_name, from_email, date, subject, body,
90 filename = filename,
91 line_count = line_count,
92 diff_lines = diff_lines,
93 )
94 }
95
96 /// Write patch content to a file and return its path.
97 fn write_patch_file(dir: &Path, name: &str, content: &str) -> PathBuf {
98 let path = dir.join(name);
99 std::fs::write(&path, content).unwrap();
100 path
101 }
102
103 // ---------------------------------------------------------------------------
104 // Tests
105 // ---------------------------------------------------------------------------
106
107 #[test]
108 fn test_import_single_patch_success() {
109 let tmp = TempDir::new().unwrap();
110 let repo = init_repo_with_commit(tmp.path(), &alice());
111
112 let patch_content = make_format_patch(
113 "Bob",
114 "bob@example.com",
115 "Add hello.txt",
116 "This patch adds a hello file.",
117 "hello.txt",
118 "Hello, world!\n",
119 );
120
121 let patch_dir = TempDir::new().unwrap();
122 let patch_file = write_patch_file(patch_dir.path(), "0001-add-hello.patch", &patch_content);
123
124 let id = patch::import(&repo, &patch_file).unwrap();
125
126 // Verify the patch was created in the DAG
127 let ref_name = format!("refs/collab/patches/{}", id);
128 let patch_state = state::PatchState::from_ref(&repo, &ref_name, &id).unwrap();
129
130 assert_eq!(patch_state.title, "Add hello.txt");
131 assert_eq!(patch_state.status, PatchStatus::Open);
132 assert_eq!(patch_state.author.name, "Alice"); // importer is the author in the DAG
133 assert!(!patch_state.head_commit.is_empty());
134 assert_eq!(patch_state.base_ref, "main");
135 }
136
137 #[test]
138 fn test_import_file_not_found() {
139 let tmp = TempDir::new().unwrap();
140 let repo = init_repo_with_commit(tmp.path(), &alice());
141
142 let nonexistent = PathBuf::from("/tmp/does-not-exist-12345.patch");
143 let result = patch::import(&repo, &nonexistent);
144 assert!(result.is_err());
145 }
146
147 #[test]
148 fn test_import_malformed_patch() {
149 let tmp = TempDir::new().unwrap();
150 let repo = init_repo_with_commit(tmp.path(), &alice());
151
152 let patch_dir = TempDir::new().unwrap();
153 let patch_file = write_patch_file(
154 patch_dir.path(),
155 "bad.patch",
156 "This is not a valid patch file at all.\nJust random text.\n",
157 );
158
159 let result = patch::import(&repo, &patch_file);
160 assert!(result.is_err());
161 }
162
163 #[test]
164 fn test_import_creates_dag_entry_readable_by_show() {
165 let tmp = TempDir::new().unwrap();
166 let repo = init_repo_with_commit(tmp.path(), &alice());
167
168 let patch_content = make_format_patch(
169 "Charlie",
170 "charlie@example.com",
171 "Fix bug in parser",
172 "Fixes an off-by-one error in the parser module.",
173 "parser.txt",
174 "fixed parser code\n",
175 );
176
177 let patch_dir = TempDir::new().unwrap();
178 let patch_file = write_patch_file(patch_dir.path(), "0001-fix-bug.patch", &patch_content);
179
180 let id = patch::import(&repo, &patch_file).unwrap();
181
182 // Verify it can be resolved and read back through state infrastructure
183 let (ref_name, resolved_id) = state::resolve_patch_ref(&repo, &id[..8]).unwrap();
184 assert_eq!(resolved_id, id);
185
186 let patch_state = state::PatchState::from_ref(&repo, &ref_name, &resolved_id).unwrap();
187 assert_eq!(patch_state.title, "Fix bug in parser");
188 assert!(
189 patch_state.body.contains("off-by-one"),
190 "body should contain the patch description"
191 );
192 }
193
194 #[test]
195 fn test_import_series_multiple_files() {
196 let tmp = TempDir::new().unwrap();
197 let repo = init_repo_with_commit(tmp.path(), &alice());
198
199 let patch1 = make_format_patch(
200 "Bob",
201 "bob@example.com",
202 "Add file one",
203 "First patch in series.",
204 "one.txt",
205 "one\n",
206 );
207
208 let patch2 = make_format_patch(
209 "Bob",
210 "bob@example.com",
211 "Add file two",
212 "Second patch in series.",
213 "two.txt",
214 "two\n",
215 );
216
217 let patch_dir = TempDir::new().unwrap();
218 let f1 = write_patch_file(patch_dir.path(), "0001-add-one.patch", &patch1);
219 let f2 = write_patch_file(patch_dir.path(), "0002-add-two.patch", &patch2);
220
221 let ids = patch::import_series(&repo, &[f1, f2]).unwrap();
222 assert_eq!(ids.len(), 2);
223
224 // Both should be valid patches in the DAG
225 for id in &ids {
226 let (ref_name, _) = state::resolve_patch_ref(&repo, &id[..8]).unwrap();
227 let ps = state::PatchState::from_ref(&repo, &ref_name, id).unwrap();
228 assert_eq!(ps.status, PatchStatus::Open);
229 }
230 }
231
232 #[test]
233 fn test_import_series_rollback_on_failure() {
234 let tmp = TempDir::new().unwrap();
235 let repo = init_repo_with_commit(tmp.path(), &alice());
236
237 let good_patch = make_format_patch(
238 "Bob",
239 "bob@example.com",
240 "Add good file",
241 "A good patch.",
242 "good.txt",
243 "good\n",
244 );
245
246 let patch_dir = TempDir::new().unwrap();
247 let f1 = write_patch_file(patch_dir.path(), "0001-good.patch", &good_patch);
248 let f2 = PathBuf::from("/tmp/nonexistent-bad-patch-12345.patch");
249
250 let result = patch::import_series(&repo, &[f1, f2]);
251 assert!(result.is_err());
252
253 // After rollback, no patches should exist
254 let patches = state::list_patches(&repo).unwrap();
255 assert_eq!(patches.len(), 0, "rollback should remove all imported patches");
256 }
257
258 #[test]
259 fn test_import_patch_with_modification() {
260 // Test importing a patch that modifies an existing file (not just new files)
261 let tmp = TempDir::new().unwrap();
262 let repo = init_repo_with_commit(tmp.path(), &alice());
263
264 // Create a patch that modifies README (which exists in our initial commit)
265 let date = "Thu, 19 Mar 2026 10:30:00 +0000";
266 let patch_content = format!(
267 "From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001\n\
268 From: Bob <bob@example.com>\n\
269 Date: {}\n\
270 Subject: [PATCH] Update README\n\
271 \n\
272 Updated the README with more info.\n\
273 ---\n\
274 README | 2 +-\n\
275 1 file changed, 1 insertion(+), 1 deletion(-)\n\
276 \n\
277 diff --git a/README b/README\n\
278 index 1234567..abcdef0 100644\n\
279 --- a/README\n\
280 +++ b/README\n\
281 @@ -1 +1 @@\n\
282 -initial content\n\
283 +updated content\n\
284 -- \n\
285 2.40.0\n",
286 date,
287 );
288
289 let patch_dir = TempDir::new().unwrap();
290 let patch_file = write_patch_file(patch_dir.path(), "0001-update-readme.patch", &patch_content);
291
292 let id = patch::import(&repo, &patch_file).unwrap();
293
294 let ref_name = format!("refs/collab/patches/{}", id);
295 let ps = state::PatchState::from_ref(&repo, &ref_name, &id).unwrap();
296 assert_eq!(ps.title, "Update README");
297 assert_eq!(ps.status, PatchStatus::Open);
298 }